✨ 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;
|
public TelegramRecord telegramRecord;
|
||||||
|
|
||||||
|
private final AvgSpeed avgSpeed = new AvgSpeed();
|
||||||
|
|
||||||
static {
|
static {
|
||||||
Client.setLogMessageHandler(0, new LogMessageHandler());
|
Client.setLogMessageHandler(0, new LogMessageHandler());
|
||||||
|
|
||||||
|
|
@ -429,6 +431,8 @@ public class TelegramVerticle extends AbstractVerticle {
|
||||||
.put("sentBytes", bytes.v1)
|
.put("sentBytes", bytes.v1)
|
||||||
.put("receivedBytes", bytes.v2)
|
.put("receivedBytes", bytes.v2)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
jsonObject.put("speedStats", avgSpeed.getSpeedStats());
|
||||||
return jsonObject;
|
return jsonObject;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -676,6 +680,7 @@ public class TelegramVerticle extends AbstractVerticle {
|
||||||
|
|
||||||
private void onFileDownloadsUpdated(TdApi.UpdateFileDownloads updateFileDownloads) {
|
private void onFileDownloadsUpdated(TdApi.UpdateFileDownloads updateFileDownloads) {
|
||||||
log.trace("[%s] Receive file downloads update: %s".formatted(getRootId(), 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));
|
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 useSWR from "swr";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
CloudDownload,
|
CloudDownload,
|
||||||
Download,
|
Download,
|
||||||
File,
|
File,
|
||||||
FileText,
|
FileText,
|
||||||
Image,
|
Image,
|
||||||
|
LineChart,
|
||||||
LoaderPinwheel,
|
LoaderPinwheel,
|
||||||
Music,
|
Music,
|
||||||
Network,
|
Network,
|
||||||
|
|
@ -16,11 +20,11 @@ import {
|
||||||
Video,
|
Video,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { request, telegramApi, type TelegramApiArg } from "@/lib/api";
|
import { request, telegramApi, type TelegramApiArg } from "@/lib/api";
|
||||||
import prettyBytes from "pretty-bytes";
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import useSWRMutation from "swr/mutation";
|
import useSWRMutation from "swr/mutation";
|
||||||
import type { TelegramApiResult } from "@/lib/types"; // Define a fetcher function to handle the API request
|
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 defining the structure of the data returned from the API
|
||||||
interface StatisticsData {
|
interface StatisticsData {
|
||||||
|
|
@ -38,6 +42,13 @@ interface StatisticsData {
|
||||||
sentBytes: number;
|
sentBytes: number;
|
||||||
receivedBytes: number;
|
receivedBytes: number;
|
||||||
};
|
};
|
||||||
|
speedStats: {
|
||||||
|
interval: number;
|
||||||
|
avgSpeed: number;
|
||||||
|
maxSpeed: number;
|
||||||
|
medianSpeed: number;
|
||||||
|
minSpeed: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Props interface for the component, expecting a telegramId as input
|
// 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 (
|
return (
|
||||||
<div className="space-y-6 rounded-lg bg-gray-50 p-2 md:p-6">
|
<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">
|
<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>
|
</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="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">
|
<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">
|
<h3 className="text-md flex items-center space-x-2 font-semibold text-gray-700">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue