1import tests from '../models/Speedtests.js';
2import { Op, Sequelize } from 'sequelize';
3import { mapFixed, mapRounded } from '../util/helpers.js';
4import { getValue } from './config.js';
5
6const DEFAULT_RETENTION_DAYS = 365;
7
8export const create = async (ping, download, upload, time, serverId, type = "auto", resultId = null, error = null, jitter = null, serverName = null, serverHost = null) => {
9 return (await tests.create({ping, jitter, download, upload, error, serverId, serverName, serverHost, type, resultId, time, created: new Date().toISOString()})).id;
10}
11
12export const getOne = async (id) => {
13 let speedtest = await tests.findByPk(id);
14 if (speedtest === null) return null;
15 if (speedtest.error === null) delete speedtest.error;
16 return speedtest
17}
18
19export const listAll = async () => {
20 let dbEntries = await tests.findAll({order: [["created", "DESC"]]});
21 for (let dbEntry of dbEntries) {
22 if (dbEntry.error === null) delete dbEntry.error;
23 if (dbEntry.resultId === null) delete dbEntry.resultId;
24 }
25
26 return dbEntries;
27}
28
29export const listTests = async (afterId, limit) => {
30 limit = parseInt(limit) || 10;
31
32 let whereClause = {};
33
34 if (afterId) whereClause.id = {[Op.lt]: afterId};
35
36 let dbEntries = await tests.findAll({
37 where: Object.keys(whereClause).length > 0 ? whereClause : undefined,
38 order: [["created", "DESC"]],
39 limit
40 });
41
42 for (let dbEntry of dbEntries) {
43 if (dbEntry.error === null) delete dbEntry.error;
44 if (dbEntry.resultId === null) delete dbEntry.resultId;
45 }
46
47 return dbEntries;
48}
49
50export const deleteTests = async () => {
51 await tests.destroy({where: {}});
52 return true;
53}
54
55export const importTests = async (data) => {
56 if (!Array.isArray(data)) return false;
57
58 for (let entry of data) {
59 if (entry.error === null) delete entry.error;
60 if (entry.resultId === null) delete entry.resultId;
61
62 if (!["custom", "auto"].includes(entry.type)) continue;
63 if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/.test(entry.created)) continue;
64
65 try {
66 console.log(entry)
67 await tests.create(entry);
68 } catch (e) {
69 }
70 }
71
72 return true;
73}
74
75export const listStatistics = async (fromDate, toDate) => {
76 const [fromYear, fromMonth, fromDay] = fromDate.split('-').map(Number);
77 const [toYear, toMonth, toDay] = toDate.split('-').map(Number);
78
79 const from = new Date(fromYear, fromMonth - 1, fromDay, 0, 0, 0, 0);
80 const to = new Date(toYear, toMonth - 1, toDay, 23, 59, 59, 999);
81
82 const dbEntries = (await tests.findAll({order: [["created", "DESC"]]}))
83 .filter((entry) => {
84 const entryDate = new Date(entry.created);
85 return entryDate >= from && entryDate <= to;
86 });
87
88 let notFailed = dbEntries.filter((entry) => entry.error === null);
89
90 const daysDiff = Math.ceil((to - from) / (1000 * 60 * 60 * 24));
91 const dataPointCount = dbEntries.length;
92 const TARGET_CHART_POINTS = 300;
93
94 let data = {};
95 ["ping", "jitter", "download", "upload", "time"].forEach(item => {
96 data[item] = notFailed.map(entry => entry[item]);
97 });
98
99 const hourlyData = {};
100 for (let i = 0; i < 24; i++) {
101 hourlyData[i] = { download: [], upload: [], ping: [], jitter: [] };
102 }
103
104 notFailed.forEach(entry => {
105 const hour = new Date(entry.created).getHours();
106 hourlyData[hour].download.push(entry.download);
107 hourlyData[hour].upload.push(entry.upload);
108 hourlyData[hour].ping.push(entry.ping);
109 if (entry.jitter !== null) hourlyData[hour].jitter.push(entry.jitter);
110 });
111
112 const hourlyAverages = Object.keys(hourlyData).map(hour => ({
113 hour: parseInt(hour),
114 download: hourlyData[hour].download.length > 0
115 ? parseFloat((hourlyData[hour].download.reduce((a, b) => a + b, 0) / hourlyData[hour].download.length).toFixed(2))
116 : null,
117 upload: hourlyData[hour].upload.length > 0
118 ? parseFloat((hourlyData[hour].upload.reduce((a, b) => a + b, 0) / hourlyData[hour].upload.length).toFixed(2))
119 : null,
120 ping: hourlyData[hour].ping.length > 0
121 ? Math.round(hourlyData[hour].ping.reduce((a, b) => a + b, 0) / hourlyData[hour].ping.length)
122 : null,
123 jitter: hourlyData[hour].jitter.length > 0
124 ? parseFloat((hourlyData[hour].jitter.reduce((a, b) => a + b, 0) / hourlyData[hour].jitter.length).toFixed(2))
125 : null,
126 count: hourlyData[hour].download.length
127 }));
128
129 const calcStdDev = (arr) => {
130 if (arr.length < 2) return 0;
131 const mean = arr.reduce((a, b) => a + b, 0) / arr.length;
132 const squaredDiffs = arr.map(val => Math.pow(val - mean, 2));
133 return Math.sqrt(squaredDiffs.reduce((a, b) => a + b, 0) / arr.length);
134 };
135
136 const downloadValues = notFailed.map(e => e.download);
137 const uploadValues = notFailed.map(e => e.upload);
138 const pingValues = notFailed.map(e => e.ping);
139
140 const downloadMean = downloadValues.length > 0 ? downloadValues.reduce((a, b) => a + b, 0) / downloadValues.length : 0;
141 const uploadMean = uploadValues.length > 0 ? uploadValues.reduce((a, b) => a + b, 0) / uploadValues.length : 0;
142
143 const consistency = {
144 download: {
145 stdDev: parseFloat(calcStdDev(downloadValues).toFixed(2)),
146 consistency: downloadMean > 0 ? parseFloat((100 - (calcStdDev(downloadValues) / downloadMean * 100)).toFixed(1)) : 100
147 },
148 upload: {
149 stdDev: parseFloat(calcStdDev(uploadValues).toFixed(2)),
150 consistency: uploadMean > 0 ? parseFloat((100 - (calcStdDev(uploadValues) / uploadMean * 100)).toFixed(1)) : 100
151 },
152 ping: {
153 stdDev: parseFloat(calcStdDev(pingValues).toFixed(2)),
154 jitter: parseFloat(calcStdDev(pingValues).toFixed(2))
155 }
156 };
157
158 let chartData = data;
159 let chartLabels = notFailed.map((entry) => new Date(entry.created).toISOString());
160 let chartFailed = notFailed.map(() => false);
161 let chartErrors = notFailed.map(() => null);
162 const allEntriesSorted = [...dbEntries].sort((a, b) => new Date(a.created) - new Date(b.created));
163
164 if (allEntriesSorted.length <= TARGET_CHART_POINTS) {
165 chartLabels = allEntriesSorted.map((entry) => new Date(entry.created).toISOString());
166 chartFailed = allEntriesSorted.map((entry) => entry.error !== null);
167 chartErrors = allEntriesSorted.map((entry) => entry.error);
168 chartData = {
169 ping: allEntriesSorted.map(entry => entry.error === null ? entry.ping : null),
170 jitter: allEntriesSorted.map(entry => entry.error === null ? entry.jitter : null),
171 download: allEntriesSorted.map(entry => entry.error === null ? entry.download : null),
172 upload: allEntriesSorted.map(entry => entry.error === null ? entry.upload : null),
173 time: allEntriesSorted.map(entry => entry.error === null ? entry.time : null)
174 };
175 } else {
176 const bucketCount = TARGET_CHART_POINTS;
177 const timeRange = to.getTime() - from.getTime();
178 const bucketSize = timeRange / bucketCount;
179
180 const buckets = [];
181 for (let i = 0; i < bucketCount; i++) {
182 buckets.push({
183 startTime: from.getTime() + (i * bucketSize),
184 endTime: from.getTime() + ((i + 1) * bucketSize),
185 entries: [],
186 failed: 0,
187 errors: []
188 });
189 }
190
191 allEntriesSorted.forEach(entry => {
192 const entryTime = new Date(entry.created).getTime();
193 const bucketIndex = Math.min(Math.floor((entryTime - from.getTime()) / bucketSize), bucketCount - 1);
194 if (bucketIndex >= 0 && bucketIndex < bucketCount) {
195 buckets[bucketIndex].entries.push(entry);
196 if (entry.error !== null) {
197 buckets[bucketIndex].failed++;
198 buckets[bucketIndex].errors.push(entry.error);
199 }
200 }
201 });
202
203 chartLabels = [];
204 chartFailed = [];
205 chartErrors = [];
206 chartData = { ping: [], jitter: [], download: [], upload: [], time: [] };
207
208 buckets.forEach((bucket, index) => {
209 const validEntries = bucket.entries.filter(e => e.error === null);
210
211 if (validEntries.length === 0) {
212 if (bucket.failed > 0) {
213 chartLabels.push(new Date(bucket.startTime + bucketSize / 2).toISOString());
214 chartFailed.push(true);
215 chartErrors.push(bucket.errors.join('; '));
216 chartData.ping.push(null);
217 chartData.jitter.push(null);
218 chartData.download.push(null);
219 chartData.upload.push(null);
220 chartData.time.push(null);
221 }
222 return;
223 }
224
225 const midTime = bucket.startTime + bucketSize / 2;
226 let closestEntry = validEntries[0];
227 let closestDiff = Math.abs(new Date(closestEntry.created).getTime() - midTime);
228
229 validEntries.forEach(entry => {
230 const diff = Math.abs(new Date(entry.created).getTime() - midTime);
231 if (diff < closestDiff) {
232 closestDiff = diff;
233 closestEntry = entry;
234 }
235 });
236
237 const avgPing = Math.round(validEntries.reduce((sum, e) => sum + e.ping, 0) / validEntries.length);
238 const jitterEntries = validEntries.filter(e => e.jitter !== null);
239 const avgJitter = jitterEntries.length > 0
240 ? parseFloat((jitterEntries.reduce((sum, e) => sum + e.jitter, 0) / jitterEntries.length).toFixed(2))
241 : null;
242 const avgDownload = parseFloat((validEntries.reduce((sum, e) => sum + e.download, 0) / validEntries.length).toFixed(2));
243 const avgUpload = parseFloat((validEntries.reduce((sum, e) => sum + e.upload, 0) / validEntries.length).toFixed(2));
244 const avgTime = Math.round(validEntries.reduce((sum, e) => sum + e.time, 0) / validEntries.length);
245
246 chartLabels.push(new Date(midTime).toISOString());
247 chartFailed.push(bucket.failed > 0);
248 chartErrors.push(bucket.failed > 0 ? `${bucket.failed} failed in period` : null);
249 chartData.ping.push(avgPing);
250 chartData.jitter.push(avgJitter);
251 chartData.download.push(avgDownload);
252 chartData.upload.push(avgUpload);
253 chartData.time.push(avgTime);
254 });
255 }
256
257 return {
258 tests: {
259 total: dbEntries.length,
260 failed: dbEntries.length - notFailed.length
261 },
262 ping: mapRounded(notFailed, "ping"),
263 jitter: mapFixed(notFailed.filter(e => e.jitter !== null), "jitter"),
264 download: mapFixed(notFailed, "download"),
265 upload: mapFixed(notFailed, "upload"),
266 time: mapRounded(notFailed, "time"),
267 data: chartData,
268 labels: chartLabels,
269 failed: chartFailed,
270 errors: chartErrors,
271 hourlyAverages,
272 consistency,
273 dataPoints: chartLabels.length,
274 rawDataPoints: dataPointCount,
275 downsampled: dataPointCount > TARGET_CHART_POINTS,
276 dateRange: {
277 from: fromDate,
278 to: toDate,
279 days: daysDiff
280 }
281 };
282}
283
284export const deleteOne = async (id) => {
285 if (await getOne(id) === null) return false;
286 await tests.destroy({where: {id: id}});
287 return true;
288}
289
290export const removeOld = async () => {
291 const stored = await getValue("retentionDays");
292 const days = parseInt(stored ?? DEFAULT_RETENTION_DAYS);
293
294 if (!Number.isFinite(days) || days <= 0) return true;
295
296 await tests.destroy({
297 where: {
298 created: process.env.DB_TYPE === "mysql"
299 ? {[Op.lte]: new Date(new Date().getTime() - days * 24 * 3600000)}
300 : {[Op.lte]: Sequelize.literal(`datetime('now', '-${days} days')`)}
301 }
302 });
303 return true;
304}
305
306export const getLatest = async () => {
307 let latest = await tests.findOne({order: [["created", "DESC"]]});
308 if (latest === null) return undefined;
309 if (latest.error === null) delete latest.error;
310 if (latest.resultId === null) delete latest.resultId;
311 return latest;
312}
313
314export const exportTests = async (fromDate, toDate) => {
315 const [fromYear, fromMonth, fromDay] = fromDate.split('-').map(Number);
316 const [toYear, toMonth, toDay] = toDate.split('-').map(Number);
317
318 const from = new Date(fromYear, fromMonth - 1, fromDay, 0, 0, 0, 0);
319 const to = new Date(toYear, toMonth - 1, toDay, 23, 59, 59, 999);
320
321 const dbEntries = (await tests.findAll({order: [["created", "ASC"]]}))
322 .filter((entry) => {
323 const entryDate = new Date(entry.created);
324 return entryDate >= from && entryDate <= to;
325 });
326
327 return dbEntries.map(entry => ({
328 id: entry.id,
329 ping: entry.ping,
330 jitter: entry.jitter,
331 download: entry.download,
332 upload: entry.upload,
333 time: entry.time,
334 type: entry.type,
335 created: entry.created,
336 error: entry.error
337 }));
338}