IKHSAN
ENID Baca dalam Bahasa Indonesia

pixies

Public
Branch
master
Latest commit
9b0697f26 October 2026

speedtests.js

Open raw
import tests from '../models/Speedtests.js';
import { Op, Sequelize } from 'sequelize';
import { mapFixed, mapRounded } from '../util/helpers.js';
import { getValue } from './config.js';

const DEFAULT_RETENTION_DAYS = 365;

export const create = async (ping, download, upload, time, serverId, type = "auto", resultId = null, error = null, jitter = null, serverName = null, serverHost = null) => {
    return (await tests.create({ping, jitter, download, upload, error, serverId, serverName, serverHost, type, resultId, time, created: new Date().toISOString()})).id;
}

export const getOne = async (id) => {
    let speedtest = await tests.findByPk(id);
    if (speedtest === null) return null;
    if (speedtest.error === null) delete speedtest.error;
    return speedtest
}

export const listAll = async () => {
    let dbEntries = await tests.findAll({order: [["created", "DESC"]]});
    for (let dbEntry of dbEntries) {
        if (dbEntry.error === null) delete dbEntry.error;
        if (dbEntry.resultId === null) delete dbEntry.resultId;
    }

    return dbEntries;
}

export const listTests = async (afterId, limit) => {
    limit = parseInt(limit) || 10;

    let whereClause = {};
    
    if (afterId) whereClause.id = {[Op.lt]: afterId};

    let dbEntries = await tests.findAll({
        where: Object.keys(whereClause).length > 0 ? whereClause : undefined, 
        order: [["created", "DESC"]], 
        limit
    });

    for (let dbEntry of dbEntries) {
        if (dbEntry.error === null) delete dbEntry.error;
        if (dbEntry.resultId === null) delete dbEntry.resultId;
    }

    return dbEntries;
}

export const deleteTests = async () => {
    await tests.destroy({where: {}});
    return true;
}

export const importTests = async (data) => {
    if (!Array.isArray(data)) return false;

    for (let entry of data) {
        if (entry.error === null) delete entry.error;
        if (entry.resultId === null) delete entry.resultId;

        if (!["custom", "auto"].includes(entry.type)) continue;
        if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/.test(entry.created)) continue;

        try {
            console.log(entry)
            await tests.create(entry);
        } catch (e) {
        }
    }

    return true;
}

export const listStatistics = async (fromDate, toDate) => {
    const [fromYear, fromMonth, fromDay] = fromDate.split('-').map(Number);
    const [toYear, toMonth, toDay] = toDate.split('-').map(Number);
    
    const from = new Date(fromYear, fromMonth - 1, fromDay, 0, 0, 0, 0);
    const to = new Date(toYear, toMonth - 1, toDay, 23, 59, 59, 999);
    
    const dbEntries = (await tests.findAll({order: [["created", "DESC"]]}))
        .filter((entry) => {
            const entryDate = new Date(entry.created);
            return entryDate >= from && entryDate <= to;
        });

    let notFailed = dbEntries.filter((entry) => entry.error === null);

    const daysDiff = Math.ceil((to - from) / (1000 * 60 * 60 * 24));
    const dataPointCount = dbEntries.length;
    const TARGET_CHART_POINTS = 300;

    let data = {};
    ["ping", "jitter", "download", "upload", "time"].forEach(item => {
        data[item] = notFailed.map(entry => entry[item]);
    });

    const hourlyData = {};
    for (let i = 0; i < 24; i++) {
        hourlyData[i] = { download: [], upload: [], ping: [], jitter: [] };
    }
    
    notFailed.forEach(entry => {
        const hour = new Date(entry.created).getHours();
        hourlyData[hour].download.push(entry.download);
        hourlyData[hour].upload.push(entry.upload);
        hourlyData[hour].ping.push(entry.ping);
        if (entry.jitter !== null) hourlyData[hour].jitter.push(entry.jitter);
    });

    const hourlyAverages = Object.keys(hourlyData).map(hour => ({
        hour: parseInt(hour),
        download: hourlyData[hour].download.length > 0 
            ? parseFloat((hourlyData[hour].download.reduce((a, b) => a + b, 0) / hourlyData[hour].download.length).toFixed(2))
            : null,
        upload: hourlyData[hour].upload.length > 0
            ? parseFloat((hourlyData[hour].upload.reduce((a, b) => a + b, 0) / hourlyData[hour].upload.length).toFixed(2))
            : null,
        ping: hourlyData[hour].ping.length > 0
            ? Math.round(hourlyData[hour].ping.reduce((a, b) => a + b, 0) / hourlyData[hour].ping.length)
            : null,
        jitter: hourlyData[hour].jitter.length > 0
            ? parseFloat((hourlyData[hour].jitter.reduce((a, b) => a + b, 0) / hourlyData[hour].jitter.length).toFixed(2))
            : null,
        count: hourlyData[hour].download.length
    }));

    const calcStdDev = (arr) => {
        if (arr.length < 2) return 0;
        const mean = arr.reduce((a, b) => a + b, 0) / arr.length;
        const squaredDiffs = arr.map(val => Math.pow(val - mean, 2));
        return Math.sqrt(squaredDiffs.reduce((a, b) => a + b, 0) / arr.length);
    };

    const downloadValues = notFailed.map(e => e.download);
    const uploadValues = notFailed.map(e => e.upload);
    const pingValues = notFailed.map(e => e.ping);

    const downloadMean = downloadValues.length > 0 ? downloadValues.reduce((a, b) => a + b, 0) / downloadValues.length : 0;
    const uploadMean = uploadValues.length > 0 ? uploadValues.reduce((a, b) => a + b, 0) / uploadValues.length : 0;

    const consistency = {
        download: {
            stdDev: parseFloat(calcStdDev(downloadValues).toFixed(2)),
            consistency: downloadMean > 0 ? parseFloat((100 - (calcStdDev(downloadValues) / downloadMean * 100)).toFixed(1)) : 100
        },
        upload: {
            stdDev: parseFloat(calcStdDev(uploadValues).toFixed(2)),
            consistency: uploadMean > 0 ? parseFloat((100 - (calcStdDev(uploadValues) / uploadMean * 100)).toFixed(1)) : 100
        },
        ping: {
            stdDev: parseFloat(calcStdDev(pingValues).toFixed(2)),
            jitter: parseFloat(calcStdDev(pingValues).toFixed(2))
        }
    };

    let chartData = data;
    let chartLabels = notFailed.map((entry) => new Date(entry.created).toISOString());
    let chartFailed = notFailed.map(() => false);
    let chartErrors = notFailed.map(() => null);
    const allEntriesSorted = [...dbEntries].sort((a, b) => new Date(a.created) - new Date(b.created));
    
    if (allEntriesSorted.length <= TARGET_CHART_POINTS) {
        chartLabels = allEntriesSorted.map((entry) => new Date(entry.created).toISOString());
        chartFailed = allEntriesSorted.map((entry) => entry.error !== null);
        chartErrors = allEntriesSorted.map((entry) => entry.error);
        chartData = {
            ping: allEntriesSorted.map(entry => entry.error === null ? entry.ping : null),
            jitter: allEntriesSorted.map(entry => entry.error === null ? entry.jitter : null),
            download: allEntriesSorted.map(entry => entry.error === null ? entry.download : null),
            upload: allEntriesSorted.map(entry => entry.error === null ? entry.upload : null),
            time: allEntriesSorted.map(entry => entry.error === null ? entry.time : null)
        };
    } else {
        const bucketCount = TARGET_CHART_POINTS;
        const timeRange = to.getTime() - from.getTime();
        const bucketSize = timeRange / bucketCount;
        
        const buckets = [];
        for (let i = 0; i < bucketCount; i++) {
            buckets.push({
                startTime: from.getTime() + (i * bucketSize),
                endTime: from.getTime() + ((i + 1) * bucketSize),
                entries: [],
                failed: 0,
                errors: []
            });
        }
        
        allEntriesSorted.forEach(entry => {
            const entryTime = new Date(entry.created).getTime();
            const bucketIndex = Math.min(Math.floor((entryTime - from.getTime()) / bucketSize), bucketCount - 1);
            if (bucketIndex >= 0 && bucketIndex < bucketCount) {
                buckets[bucketIndex].entries.push(entry);
                if (entry.error !== null) {
                    buckets[bucketIndex].failed++;
                    buckets[bucketIndex].errors.push(entry.error);
                }
            }
        });
        
        chartLabels = [];
        chartFailed = [];
        chartErrors = [];
        chartData = { ping: [], jitter: [], download: [], upload: [], time: [] };
        
        buckets.forEach((bucket, index) => {
            const validEntries = bucket.entries.filter(e => e.error === null);
            
            if (validEntries.length === 0) {
                if (bucket.failed > 0) {
                    chartLabels.push(new Date(bucket.startTime + bucketSize / 2).toISOString());
                    chartFailed.push(true);
                    chartErrors.push(bucket.errors.join('; '));
                    chartData.ping.push(null);
                    chartData.jitter.push(null);
                    chartData.download.push(null);
                    chartData.upload.push(null);
                    chartData.time.push(null);
                }
                return;
            }
            
            const midTime = bucket.startTime + bucketSize / 2;
            let closestEntry = validEntries[0];
            let closestDiff = Math.abs(new Date(closestEntry.created).getTime() - midTime);
            
            validEntries.forEach(entry => {
                const diff = Math.abs(new Date(entry.created).getTime() - midTime);
                if (diff < closestDiff) {
                    closestDiff = diff;
                    closestEntry = entry;
                }
            });
            
            const avgPing = Math.round(validEntries.reduce((sum, e) => sum + e.ping, 0) / validEntries.length);
            const jitterEntries = validEntries.filter(e => e.jitter !== null);
            const avgJitter = jitterEntries.length > 0 
                ? parseFloat((jitterEntries.reduce((sum, e) => sum + e.jitter, 0) / jitterEntries.length).toFixed(2))
                : null;
            const avgDownload = parseFloat((validEntries.reduce((sum, e) => sum + e.download, 0) / validEntries.length).toFixed(2));
            const avgUpload = parseFloat((validEntries.reduce((sum, e) => sum + e.upload, 0) / validEntries.length).toFixed(2));
            const avgTime = Math.round(validEntries.reduce((sum, e) => sum + e.time, 0) / validEntries.length);
            
            chartLabels.push(new Date(midTime).toISOString());
            chartFailed.push(bucket.failed > 0);
            chartErrors.push(bucket.failed > 0 ? `${bucket.failed} failed in period` : null);
            chartData.ping.push(avgPing);
            chartData.jitter.push(avgJitter);
            chartData.download.push(avgDownload);
            chartData.upload.push(avgUpload);
            chartData.time.push(avgTime);
        });
    }

    return {
        tests: {
            total: dbEntries.length,
            failed: dbEntries.length - notFailed.length
        },
        ping: mapRounded(notFailed, "ping"),
        jitter: mapFixed(notFailed.filter(e => e.jitter !== null), "jitter"),
        download: mapFixed(notFailed, "download"),
        upload: mapFixed(notFailed, "upload"),
        time: mapRounded(notFailed, "time"),
        data: chartData,
        labels: chartLabels,
        failed: chartFailed,
        errors: chartErrors,
        hourlyAverages,
        consistency,
        dataPoints: chartLabels.length,
        rawDataPoints: dataPointCount,
        downsampled: dataPointCount > TARGET_CHART_POINTS,
        dateRange: {
            from: fromDate,
            to: toDate,
            days: daysDiff
        }
    };
}

export const deleteOne = async (id) => {
    if (await getOne(id) === null) return false;
    await tests.destroy({where: {id: id}});
    return true;
}

export const removeOld = async () => {
    const stored = await getValue("retentionDays");
    const days = parseInt(stored ?? DEFAULT_RETENTION_DAYS);

    if (!Number.isFinite(days) || days <= 0) return true;

    await tests.destroy({
        where: {
            created: process.env.DB_TYPE === "mysql"
                ? {[Op.lte]: new Date(new Date().getTime() - days * 24 * 3600000)}
                : {[Op.lte]: Sequelize.literal(`datetime('now', '-${days} days')`)}
        }
    });
    return true;
}

export const getLatest = async () => {
    let latest = await tests.findOne({order: [["created", "DESC"]]});
    if (latest === null) return undefined;
    if (latest.error === null) delete latest.error;
    if (latest.resultId === null) delete latest.resultId;
    return latest;
}

export const exportTests = async (fromDate, toDate) => {
    const [fromYear, fromMonth, fromDay] = fromDate.split('-').map(Number);
    const [toYear, toMonth, toDay] = toDate.split('-').map(Number);
    
    const from = new Date(fromYear, fromMonth - 1, fromDay, 0, 0, 0, 0);
    const to = new Date(toYear, toMonth - 1, toDay, 23, 59, 59, 999);
    
    const dbEntries = (await tests.findAll({order: [["created", "ASC"]]}))
        .filter((entry) => {
            const entryDate = new Date(entry.created);
            return entryDate >= from && entryDate <= to;
        });

    return dbEntries.map(entry => ({
        id: entry.id,
        ping: entry.ping,
        jitter: entry.jitter,
        download: entry.download,
        upload: entry.upload,
        time: entry.time,
        type: entry.type,
        created: entry.created,
        error: entry.error
    }));
}
bytes
13,490
lines
338
Type
js
Latest commit
26 October 2026