✨ feat: Added AvgSpeed class to handle download speed calculations and statistics.
This commit is contained in:
parent
dd19756efb
commit
d47ed3f5fe
4 changed files with 427 additions and 1 deletions
187
api/src/main/java/telegram/files/AvgSpeed.java
Normal file
187
api/src/main/java/telegram/files/AvgSpeed.java
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package telegram.files;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class AvgSpeed {
|
||||
private final int interval;
|
||||
|
||||
private final TreeMap<Long, SpeedPoint> speedPoints;
|
||||
|
||||
private long maxSpeed;
|
||||
|
||||
private long minSpeed;
|
||||
|
||||
private final int smoothingWindowSize;
|
||||
|
||||
/**
|
||||
* @param speed Speed since last point
|
||||
*/
|
||||
record SpeedPoint(long totalSize, long downloadedSize, long speed) {
|
||||
}
|
||||
|
||||
public AvgSpeed() {
|
||||
this(15 * 60); // Default interval is 15 minutes
|
||||
}
|
||||
|
||||
public AvgSpeed(int interval) {
|
||||
this(interval, 5);
|
||||
}
|
||||
|
||||
public AvgSpeed(int interval, int smoothingWindowSize) {
|
||||
this.interval = interval;
|
||||
this.speedPoints = new TreeMap<>();
|
||||
this.maxSpeed = 0;
|
||||
this.minSpeed = Long.MAX_VALUE;
|
||||
this.smoothingWindowSize = smoothingWindowSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update download progress
|
||||
*/
|
||||
public void update(long totalSize, long downloadedSize, long timestamp) {
|
||||
// Calculate speed since last point
|
||||
long speed = calculateSpeed(downloadedSize, timestamp);
|
||||
|
||||
// Apply smoothing if we have enough points
|
||||
if (speedPoints.size() >= smoothingWindowSize) {
|
||||
speed = smoothSpeed(speed);
|
||||
}
|
||||
|
||||
// Update max/min speeds only for non-zero speeds
|
||||
if (speed > 0) {
|
||||
maxSpeed = Math.max(maxSpeed, speed);
|
||||
minSpeed = Math.min(minSpeed, speed);
|
||||
}
|
||||
|
||||
// Add new speed point
|
||||
speedPoints.put(timestamp, new SpeedPoint(totalSize, downloadedSize, speed));
|
||||
|
||||
// Remove old points outside the interval
|
||||
long cutoffTime = timestamp - interval * 1000L; // Convert interval to milliseconds
|
||||
speedPoints.headMap(cutoffTime).clear();
|
||||
}
|
||||
|
||||
private long calculateSpeed(long downloadedSize, long timestamp) {
|
||||
if (speedPoints.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
Map.Entry<Long, SpeedPoint> lastEntry = speedPoints.lastEntry();
|
||||
long timeDiff = timestamp - lastEntry.getKey();
|
||||
if (timeDiff <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
long bytesDiff = downloadedSize - lastEntry.getValue().downloadedSize;
|
||||
if (bytesDiff < 0) {
|
||||
// Handle download restart
|
||||
bytesDiff = downloadedSize;
|
||||
}
|
||||
|
||||
return (bytesDiff * 1000L) / timeDiff; // Speed in bytes per second
|
||||
}
|
||||
|
||||
private long smoothSpeed(long currentSpeed) {
|
||||
List<Long> recentSpeeds = speedPoints.values().stream()
|
||||
.skip(Math.max(0, speedPoints.size() - smoothingWindowSize))
|
||||
.map(point -> point.speed)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
recentSpeeds.add(currentSpeed);
|
||||
|
||||
// Sort speeds to find median
|
||||
Collections.sort(recentSpeeds);
|
||||
long median = recentSpeeds.get(recentSpeeds.size() / 2);
|
||||
|
||||
// Filter out speeds that deviate too much from median
|
||||
double threshold = median * 2.0;
|
||||
List<Long> filteredSpeeds = recentSpeeds.stream()
|
||||
.filter(speed -> speed <= threshold)
|
||||
.toList();
|
||||
|
||||
// Calculate average of remaining speeds
|
||||
return (long) filteredSpeeds.stream()
|
||||
.mapToLong(Long::longValue)
|
||||
.average()
|
||||
.orElse(currentSpeed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average speed in bytes per second for last interval
|
||||
*/
|
||||
public long getSpeed() {
|
||||
if (speedPoints.size() < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
Map.Entry<Long, SpeedPoint> firstEntry = speedPoints.firstEntry();
|
||||
Map.Entry<Long, SpeedPoint> lastEntry = speedPoints.lastEntry();
|
||||
|
||||
long timeDiff = lastEntry.getKey() - firstEntry.getKey();
|
||||
if (timeDiff <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
long bytesDownloaded = lastEntry.getValue().downloadedSize - firstEntry.getValue().downloadedSize;
|
||||
if (bytesDownloaded < 0) {
|
||||
bytesDownloaded = lastEntry.getValue().downloadedSize;
|
||||
}
|
||||
|
||||
return (bytesDownloaded * 1000L) / timeDiff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get median speed from all recorded points
|
||||
*/
|
||||
public long getMedianSpeed() {
|
||||
if (speedPoints.size() < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<Long> speeds = speedPoints.values().stream()
|
||||
.map(point -> point.speed)
|
||||
.filter(speed -> speed > 0)
|
||||
.sorted()
|
||||
.toList();
|
||||
|
||||
if (speeds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return speeds.get(speeds.size() / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum recorded speed
|
||||
*/
|
||||
public long getMaxSpeed() {
|
||||
return maxSpeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum recorded speed
|
||||
*/
|
||||
public long getMinSpeed() {
|
||||
return minSpeed == Long.MAX_VALUE ? 0 : minSpeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get speed statistics summary
|
||||
*/
|
||||
public SpeedStats getSpeedStats() {
|
||||
return new SpeedStats(
|
||||
interval,
|
||||
getSpeed(),
|
||||
getMedianSpeed(),
|
||||
getMaxSpeed(),
|
||||
getMinSpeed()
|
||||
);
|
||||
}
|
||||
|
||||
public record SpeedStats(int interval, long avgSpeed, long medianSpeed, long maxSpeed, long minSpeed) {
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,8 @@ public class TelegramVerticle extends AbstractVerticle {
|
|||
|
||||
public TelegramRecord telegramRecord;
|
||||
|
||||
private final AvgSpeed avgSpeed = new AvgSpeed();
|
||||
|
||||
static {
|
||||
Client.setLogMessageHandler(0, new LogMessageHandler());
|
||||
|
||||
|
|
@ -429,6 +431,8 @@ public class TelegramVerticle extends AbstractVerticle {
|
|||
.put("sentBytes", bytes.v1)
|
||||
.put("receivedBytes", bytes.v2)
|
||||
);
|
||||
|
||||
jsonObject.put("speedStats", avgSpeed.getSpeedStats());
|
||||
return jsonObject;
|
||||
});
|
||||
}
|
||||
|
|
@ -676,6 +680,7 @@ public class TelegramVerticle extends AbstractVerticle {
|
|||
|
||||
private void onFileDownloadsUpdated(TdApi.UpdateFileDownloads updateFileDownloads) {
|
||||
log.trace("[%s] Receive file downloads update: %s".formatted(getRootId(), updateFileDownloads));
|
||||
avgSpeed.update(updateFileDownloads.totalSize, updateFileDownloads.downloadedSize, System.currentTimeMillis());
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_DOWNLOAD, updateFileDownloads));
|
||||
}
|
||||
|
||||
|
|
|
|||
164
api/src/test/java/telegram/files/AvgSpeedTest.java
Normal file
164
api/src/test/java/telegram/files/AvgSpeedTest.java
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package telegram.files;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class AvgSpeedTest {
|
||||
private AvgSpeed avgSpeed;
|
||||
|
||||
private static final int TEST_INTERVAL = 100; // 100 seconds for testing
|
||||
|
||||
private static final int SMOOTHING_WINDOW = 3;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
avgSpeed = new AvgSpeed(TEST_INTERVAL, SMOOTHING_WINDOW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInitialState() {
|
||||
assertEquals(0, avgSpeed.getSpeed());
|
||||
assertEquals(0, avgSpeed.getMedianSpeed());
|
||||
assertEquals(0, avgSpeed.getMaxSpeed());
|
||||
assertEquals(0, avgSpeed.getMinSpeed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConstantSpeed() {
|
||||
// Simulate constant download speed of 100 bytes per second
|
||||
long baseTime = System.currentTimeMillis();
|
||||
long totalSize = 1000;
|
||||
|
||||
avgSpeed.update(totalSize, 0L, baseTime);
|
||||
avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec for 10 seconds
|
||||
avgSpeed.update(totalSize, 2000L, baseTime + 20000); // 100 bytes/sec for another 10 seconds
|
||||
|
||||
AvgSpeed.SpeedStats stats = avgSpeed.getSpeedStats();
|
||||
assertEquals(100, stats.avgSpeed(), 1.0, "Average speed should be 100 bytes/sec");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSpeedWithPause() {
|
||||
long baseTime = System.currentTimeMillis();
|
||||
long totalSize = 1000;
|
||||
|
||||
// Download at 100 bytes/sec
|
||||
avgSpeed.update(totalSize, 0L, baseTime);
|
||||
avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec
|
||||
|
||||
// Pause for 10 seconds
|
||||
avgSpeed.update(totalSize, 1000L, baseTime + 20000);
|
||||
|
||||
// Resume at 100 bytes/sec
|
||||
avgSpeed.update(totalSize, 2000L, baseTime + 30000);
|
||||
|
||||
// Average speed should be: 2000 bytes / 30 seconds = 66.67 bytes/sec
|
||||
assertEquals(67, avgSpeed.getSpeed(), 1.0,
|
||||
"Speed should account for pause period");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDownloadRestart() {
|
||||
long baseTime = System.currentTimeMillis();
|
||||
long totalSize = 1000;
|
||||
|
||||
// Initial download
|
||||
avgSpeed.update(totalSize, 0L, baseTime);
|
||||
avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec
|
||||
|
||||
// Restart download
|
||||
avgSpeed.update(totalSize, 0L, baseTime + 15000);
|
||||
avgSpeed.update(totalSize, 500L, baseTime + 20000); // 100 bytes/sec after restart
|
||||
|
||||
AvgSpeed.SpeedStats stats = avgSpeed.getSpeedStats();
|
||||
assertTrue(stats.maxSpeed() >= 100,
|
||||
"Max speed should capture the highest speed segment");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSpeedFluctuation() {
|
||||
long baseTime = System.currentTimeMillis();
|
||||
long totalSize = 1000;
|
||||
|
||||
avgSpeed.update(totalSize, 0L, baseTime);
|
||||
avgSpeed.update(totalSize, 500L, baseTime + 5000); // 100 bytes/sec
|
||||
avgSpeed.update(totalSize, 2500L, baseTime + 10000); // 400 bytes/sec
|
||||
avgSpeed.update(totalSize, 3000L, baseTime + 15000); // 100 bytes/sec
|
||||
|
||||
AvgSpeed.SpeedStats stats = avgSpeed.getSpeedStats();
|
||||
assertTrue(stats.maxSpeed() >= 300,
|
||||
"Max speed should capture high speed period");
|
||||
assertTrue(stats.minSpeed() <= 100,
|
||||
"Min speed should capture low speed period");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDataSmoothing() {
|
||||
long baseTime = System.currentTimeMillis();
|
||||
long totalSize = 1000;
|
||||
|
||||
avgSpeed.update(totalSize, 0L, baseTime);
|
||||
avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec
|
||||
avgSpeed.update(totalSize, 2000L, baseTime + 20000); // 100 bytes/sec
|
||||
avgSpeed.update(totalSize, 7000L, baseTime + 30000); // 500 bytes/sec (spike)
|
||||
avgSpeed.update(totalSize, 8000L, baseTime + 40000); // 100 bytes/sec
|
||||
|
||||
AvgSpeed.SpeedStats stats = avgSpeed.getSpeedStats();
|
||||
assertTrue(stats.avgSpeed() < 300,
|
||||
"Smoothed speed should be less than the spike value");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOldDataRemoval() {
|
||||
long baseTime = System.currentTimeMillis();
|
||||
long totalSize = 1000;
|
||||
|
||||
// Add initial points
|
||||
avgSpeed.update(totalSize, 0L, baseTime);
|
||||
avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec
|
||||
|
||||
// Add point beyond interval
|
||||
// Note: Need to move forward by interval milliseconds plus a buffer
|
||||
avgSpeed.update(totalSize, 2000L, baseTime + (TEST_INTERVAL * 1000L) + 20000);
|
||||
|
||||
long speed = avgSpeed.getSpeed();
|
||||
assertTrue(speed < 50,
|
||||
"Speed should be very low when most points are outside the interval");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMedianCalculation() {
|
||||
long baseTime = System.currentTimeMillis();
|
||||
long totalSize = 1000;
|
||||
|
||||
// Create a sequence of very distinct speeds for clear median
|
||||
avgSpeed.update(totalSize, 0L, baseTime);
|
||||
avgSpeed.update(totalSize, 1000L, baseTime + 5000); // 200 bytes/sec
|
||||
avgSpeed.update(totalSize, 1500L, baseTime + 10000); // 100 bytes/sec
|
||||
avgSpeed.update(totalSize, 3500L, baseTime + 15000); // 400 bytes/sec
|
||||
|
||||
long medianSpeed = avgSpeed.getMedianSpeed();
|
||||
assertTrue(medianSpeed >= 100 && medianSpeed <= 400,
|
||||
"Median speed should be between the lowest and highest speeds");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtremeCases() {
|
||||
long baseTime = System.currentTimeMillis();
|
||||
|
||||
// Test with zero time difference
|
||||
avgSpeed.update(1000L, 0L, baseTime);
|
||||
avgSpeed.update(1000L, 100L, baseTime);
|
||||
assertEquals(0, avgSpeed.getSpeed(),
|
||||
"Speed should be 0 when time difference is 0");
|
||||
|
||||
// Test with large downloads
|
||||
avgSpeed.update(1000000L, 0L, baseTime + 1000);
|
||||
avgSpeed.update(1000000L, 500000L, baseTime + 6000); // 100,000 bytes/sec
|
||||
assertTrue(avgSpeed.getSpeed() > 0,
|
||||
"Speed should be calculated correctly with large numbers");
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,16 @@ import React from "react";
|
|||
import useSWR from "swr";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
CloudDownload,
|
||||
Download,
|
||||
File,
|
||||
FileText,
|
||||
Image,
|
||||
LineChart,
|
||||
LoaderPinwheel,
|
||||
Music,
|
||||
Network,
|
||||
|
|
@ -16,11 +20,11 @@ import {
|
|||
Video,
|
||||
} from "lucide-react";
|
||||
import { request, telegramApi, type TelegramApiArg } from "@/lib/api";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import type { TelegramApiResult } from "@/lib/types"; // Define a fetcher function to handle the API request
|
||||
import prettyBytes from "pretty-bytes";
|
||||
|
||||
// Interface defining the structure of the data returned from the API
|
||||
interface StatisticsData {
|
||||
|
|
@ -38,6 +42,13 @@ interface StatisticsData {
|
|||
sentBytes: number;
|
||||
receivedBytes: number;
|
||||
};
|
||||
speedStats: {
|
||||
interval: number;
|
||||
avgSpeed: number;
|
||||
maxSpeed: number;
|
||||
medianSpeed: number;
|
||||
minSpeed: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Props interface for the component, expecting a telegramId as input
|
||||
|
|
@ -124,6 +135,37 @@ const FileStatistics: React.FC<FileStatisticsProps> = ({ telegramId }) => {
|
|||
},
|
||||
];
|
||||
|
||||
const avgStatFields = [
|
||||
{
|
||||
label: "Avg",
|
||||
value: prettyBytes(data.speedStats.avgSpeed, { bits: true }) + "/s",
|
||||
icon: PauseCircle,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-100",
|
||||
},
|
||||
{
|
||||
label: "Max",
|
||||
value: prettyBytes(data.speedStats.maxSpeed, { bits: true }) + "/s",
|
||||
icon: ArrowUp,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-100",
|
||||
},
|
||||
{
|
||||
label: "Median",
|
||||
value: prettyBytes(data.speedStats.medianSpeed, { bits: true }) + "/s",
|
||||
icon: LineChart,
|
||||
color: "text-purple-500",
|
||||
bgColor: "bg-purple-100",
|
||||
},
|
||||
{
|
||||
label: "Min",
|
||||
value: prettyBytes(data.speedStats.minSpeed, { bits: true }) + "/s",
|
||||
icon: ArrowDown,
|
||||
color: "text-red-500",
|
||||
bgColor: "bg-red-100",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 rounded-lg bg-gray-50 p-2 md:p-6">
|
||||
<div className="flex-1 rounded-lg bg-white p-4 shadow-md">
|
||||
|
|
@ -182,6 +224,34 @@ const FileStatistics: React.FC<FileStatisticsProps> = ({ telegramId }) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 rounded-lg bg-white p-4 shadow-md">
|
||||
<div className="flex items-center space-x-3 border-gray-200">
|
||||
<h3 className="text-md flex items-center space-x-2 font-semibold text-gray-700">
|
||||
<Clock className="h-5 w-5 text-yellow-500" />
|
||||
<span>Speed Statistics</span>
|
||||
<span className="text-sm text-gray-500 font-medium">({data.speedStats.interval / 60} minute interval)</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{avgStatFields.map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col space-y-3 rounded-xl border border-gray-100 p-4 transition-colors hover:border-gray-200"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`rounded-lg p-2 ${stat.bgColor}`}>
|
||||
<stat.icon className={`h-5 w-5 ${stat.color}`} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500">
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-semibold">{stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<div className="flex-1 rounded-lg bg-white p-4 shadow-md md:col-span-2">
|
||||
<h3 className="text-md flex items-center space-x-2 font-semibold text-gray-700">
|
||||
|
|
|
|||
Loading…
Reference in a new issue