diff --git a/api/src/main/java/telegram/files/DataVerticle.java b/api/src/main/java/telegram/files/DataVerticle.java index 513d100..b32ad32 100644 --- a/api/src/main/java/telegram/files/DataVerticle.java +++ b/api/src/main/java/telegram/files/DataVerticle.java @@ -1,5 +1,6 @@ package telegram.files; +import cn.hutool.core.lang.Version; import cn.hutool.core.util.StrUtil; import cn.hutool.log.Log; import cn.hutool.log.LogFactory; @@ -9,11 +10,12 @@ import io.vertx.core.Promise; import io.vertx.jdbcclient.JDBCConnectOptions; import io.vertx.jdbcclient.JDBCPool; import io.vertx.sqlclient.PoolOptions; -import telegram.files.repository.FileRepository; -import telegram.files.repository.SettingRepository; -import telegram.files.repository.TelegramRepository; +import io.vertx.sqlclient.SqlConnection; +import org.jooq.lambda.tuple.Tuple; +import telegram.files.repository.*; import telegram.files.repository.impl.FileRepositoryImpl; import telegram.files.repository.impl.SettingRepositoryImpl; +import telegram.files.repository.impl.StatisticRepositoryImpl; import telegram.files.repository.impl.TelegramRepositoryImpl; import java.io.File; @@ -31,6 +33,8 @@ public class DataVerticle extends AbstractVerticle { public static SettingRepository settingRepository; + public static StatisticRepository statisticRepository; + public void start(Promise stopPromise) { pool = JDBCPool.pool(vertx, new JDBCConnectOptions() @@ -38,14 +42,26 @@ public class DataVerticle extends AbstractVerticle { , new PoolOptions().setMaxSize(16).setName("pool-tf") ); + settingRepository = new SettingRepositoryImpl(pool); telegramRepository = new TelegramRepositoryImpl(pool); fileRepository = new FileRepositoryImpl(pool); - settingRepository = new SettingRepositoryImpl(pool); - Future.all(List.of( - telegramRepository.init(), - fileRepository.init(), - settingRepository.init() - )) + statisticRepository = new StatisticRepositoryImpl(pool); + List definitions = List.of( + new SettingRecord.SettingRecordDefinition(), + new TelegramRecord.TelegramRecordDefinition(), + new FileRecord.FileRecordDefinition(), + new StatisticRecord.StatisticRecordDefinition() + ); + pool.getConnection() + .compose(conn -> Future.all(definitions.stream().map(d -> d.createTable(conn)).toList()).map(conn)) + .compose(conn -> settingRepository.getByKey(SettingKey.version).map(v -> Tuple.tuple(conn, v))) + .compose(tuple -> { + SqlConnection conn = tuple.v1; + Version version = tuple.v2 == null ? new Version("0.0.0") : tuple.v2; + return Future.all(definitions.stream().map(d -> d.migrate(conn, version, new Version(Start.VERSION))).toList()); + }) + .compose(r -> + settingRepository.createOrUpdate(SettingKey.version.name(), Start.VERSION)) .onSuccess(r -> { log.info("Database initialized"); stopPromise.complete(); diff --git a/api/src/main/java/telegram/files/HttpVerticle.java b/api/src/main/java/telegram/files/HttpVerticle.java index f6dff9f..9f2ad68 100644 --- a/api/src/main/java/telegram/files/HttpVerticle.java +++ b/api/src/main/java/telegram/files/HttpVerticle.java @@ -398,12 +398,19 @@ public class HttpVerticle extends AbstractVerticle { ctx.fail(400); return; } - getTelegramVerticle(telegramId) - .ifPresentOrElse(telegramVerticle -> - telegramVerticle.getDownloadStatistics() - .onSuccess(ctx::json) - .onFailure(ctx::fail), - () -> ctx.fail(404)); + Optional telegramVerticleOptional = getTelegramVerticle(telegramId); + if (telegramVerticleOptional.isEmpty()) { + ctx.fail(404); + return; + } + TelegramVerticle telegramVerticle = telegramVerticleOptional.get(); + + String type = ctx.request().getParam("type"); + String timeRange = ctx.request().getParam("timeRange"); + (Objects.equals(type, "phase") ? telegramVerticle. getDownloadStatisticsByPhase(Convert.toInt(timeRange, 1)) : + telegramVerticle.getDownloadStatistics()) + .onSuccess(ctx::json) + .onFailure(ctx::fail); } private void handleTelegramChange(RoutingContext ctx) { diff --git a/api/src/main/java/telegram/files/MessyUtils.java b/api/src/main/java/telegram/files/MessyUtils.java new file mode 100644 index 0000000..76f6e68 --- /dev/null +++ b/api/src/main/java/telegram/files/MessyUtils.java @@ -0,0 +1,13 @@ +package telegram.files; + +import java.time.LocalDateTime; + +public class MessyUtils { + + public static LocalDateTime withGrouping5Minutes(LocalDateTime time) { + int minute = time.getMinute(); + int minuteGroup = minute / 5; + int newMinute = minuteGroup * 5; + return time.withMinute(newMinute).withSecond(0).withNano(0); + } +} diff --git a/api/src/main/java/telegram/files/Start.java b/api/src/main/java/telegram/files/Start.java index 9197507..3f534a1 100644 --- a/api/src/main/java/telegram/files/Start.java +++ b/api/src/main/java/telegram/files/Start.java @@ -9,7 +9,7 @@ import java.util.concurrent.CountDownLatch; public class Start { private static final Log log = LogFactory.get(); - public static final String VERSION = "0.1.6"; + public static final String VERSION = "0.1.7"; private static final CountDownLatch shutdownLatch = new CountDownLatch(1); diff --git a/api/src/main/java/telegram/files/TdApiHelp.java b/api/src/main/java/telegram/files/TdApiHelp.java index af611b8..84cac3b 100644 --- a/api/src/main/java/telegram/files/TdApiHelp.java +++ b/api/src/main/java/telegram/files/TdApiHelp.java @@ -276,7 +276,9 @@ public class TdApiHelp { Base64.encode((byte[]) BeanUtil.getProperty(content, "photo.minithumbnail.data")), content.caption.text, null, - "idle" + "idle", + System.currentTimeMillis(), + null ); } } @@ -329,7 +331,9 @@ public class TdApiHelp { Base64.encode((byte[]) BeanUtil.getProperty(content, "video.minithumbnail.data")), content.caption.text, null, - "idle" + "idle", + System.currentTimeMillis(), + null ); } } @@ -369,7 +373,9 @@ public class TdApiHelp { Base64.encode((byte[]) BeanUtil.getProperty(content, "audio.albumCoverMinithumbnail.data")), content.caption.text, null, - "idle" + "idle", + System.currentTimeMillis(), + null ); } } @@ -409,7 +415,9 @@ public class TdApiHelp { Base64.encode((byte[]) BeanUtil.getProperty(content, "document.minithumbnail.data")), content.caption.text, null, - "idle" + "idle", + System.currentTimeMillis(), + null ); } } diff --git a/api/src/main/java/telegram/files/TelegramVerticle.java b/api/src/main/java/telegram/files/TelegramVerticle.java index 1007a60..264fbfb 100644 --- a/api/src/main/java/telegram/files/TelegramVerticle.java +++ b/api/src/main/java/telegram/files/TelegramVerticle.java @@ -4,6 +4,9 @@ package telegram.files; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.codec.Base64; import cn.hutool.core.convert.Convert; +import cn.hutool.core.date.DateField; +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.DateTime; import cn.hutool.core.date.DateUtil; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.ArrayUtil; @@ -100,6 +103,7 @@ public class TelegramVerticle extends AbstractVerticle { telegramUpdateHandler.setOnFileDownloadsUpdated(this::onFileDownloadsUpdated); telegramUpdateHandler.setOnMessageReceived(this::onMessageReceived); client = Client.create(telegramUpdateHandler, this::handleException, this::handleException); + vertx.setPeriodic(60 * 1000, id -> handleSaveAvgSpeed()); this.enableProxy(this.proxyName) .onSuccess(r -> startPromise.complete()) .onFailure(startPromise::fail); @@ -474,6 +478,72 @@ public class TelegramVerticle extends AbstractVerticle { }); } + public Future getDownloadStatisticsByPhase(Integer timeRange) { + // 1: 1 hour, 2: 1 day, 3: 1 week, 4: 1 month + long endTime = System.currentTimeMillis(); + long startTime = switch (timeRange) { + case 1 -> DateUtil.offsetHour(DateUtil.date(), -1).getTime(); + case 2 -> DateUtil.offsetDay(DateUtil.date(), -1).getTime(); + case 3 -> DateUtil.offsetWeek(DateUtil.date(), -1).getTime(); + case 4 -> DateUtil.offsetMonth(DateUtil.date(), -1).getTime(); + default -> throw new IllegalStateException("Unexpected value: " + timeRange); + }; + + return Future.all( + DataVerticle.statisticRepository.getRangeStatistics(StatisticRecord.Type.speed, this.telegramRecord.id(), startTime, endTime) + .map(statisticRecords -> { + TreeMap> groupedSpeedStats = new TreeMap<>(Comparator.comparing( + switch (timeRange) { + case 1, 2 -> (Function) time -> + DateUtil.parse(time, DatePattern.NORM_DATETIME_MINUTE_FORMAT); + case 3, 4 -> DateUtil::parseDate; + default -> throw new IllegalStateException("Unexpected value: " + timeRange); + } + )); + for (StatisticRecord record : statisticRecords) { + JsonObject data = new JsonObject(record.data()); + long timestamp = record.timestamp(); + String time = switch (timeRange) { + case 1 -> + MessyUtils.withGrouping5Minutes(DateUtil.toLocalDateTime(DateUtil.date(timestamp))).format(DatePattern.NORM_DATETIME_MINUTE_FORMATTER); + case 2 -> + DateUtil.date(timestamp).setField(DateField.MINUTE, 0).toString(DatePattern.NORM_DATETIME_MINUTE_FORMAT); + case 3, 4 -> + DateUtil.date(timestamp).setField(DateField.MINUTE, 0).toString(DatePattern.NORM_DATE_FORMAT); + default -> throw new IllegalStateException("Unexpected value: " + timeRange); + }; + groupedSpeedStats.computeIfAbsent(time, k -> new ArrayList<>()).add(data); + } + return groupedSpeedStats.entrySet().stream() + .map(entry -> { + JsonObject speedStat = entry.getValue().stream().reduce(new JsonObject() + .put("avgSpeed", 0) + .put("medianSpeed", 0) + .put("maxSpeed", 0) + .put("minSpeed", Long.MAX_VALUE), + (a, b) -> new JsonObject() + .put("avgSpeed", a.getLong("avgSpeed") + b.getLong("avgSpeed")) + .put("medianSpeed", a.getLong("medianSpeed") + b.getLong("medianSpeed")) + .put("maxSpeed", Math.max(a.getLong("maxSpeed"), b.getLong("maxSpeed"))) + .put("minSpeed", Math.min(a.getLong("minSpeed"), b.getLong("minSpeed"))) + ); + int size = entry.getValue().size(); + speedStat.put("avgSpeed", speedStat.getLong("avgSpeed") / size) + .put("medianSpeed", speedStat.getLong("medianSpeed") / size); + return new JsonObject() + .put("time", entry.getKey()) + .put("data", speedStat); + }) + .toList(); + }), + DataVerticle.fileRepository.getCompletedRangeStatistics(this.telegramRecord.id(), startTime, endTime, timeRange) + ) + .map(r -> new JsonObject() + .put("speedStats", r.resultAt(0)) + .put("completedStats", r.resultAt(1)) + ); + } + public Future enableProxy(String proxyName) { if (StrUtil.isBlank(proxyName)) return Future.succeededFuture(); return DataVerticle.settingRepository.getByKey(SettingKey.proxys) @@ -625,6 +695,23 @@ public class TelegramVerticle extends AbstractVerticle { log.error(e); } + private void handleSaveAvgSpeed() { + if (!authorized || telegramRecord == null) return; + AvgSpeed.SpeedStats speedStats = avgSpeed.getSpeedStats(); + if (speedStats.avgSpeed() == 0 + && speedStats.minSpeed() == 0 + && speedStats.medianSpeed() == 0 + && speedStats.maxSpeed() == 0) { + return; + } + JsonObject data = JsonObject.mapFrom(speedStats); + data.remove("interval"); + DataVerticle.statisticRepository.create(new StatisticRecord(Convert.toStr(telegramRecord.id()), + StatisticRecord.Type.speed, + System.currentTimeMillis(), + data.encode())); + } + private void onAuthorizationStateUpdated(TdApi.AuthorizationState authorizationState) { log.debug("[%s] Receive authorization state update: %s".formatted(getRootId(), authorizationState)); this.lastAuthorizationState = authorizationState; @@ -695,20 +782,24 @@ public class TelegramVerticle extends AbstractVerticle { TdApi.File file = updateFile.file; if (file != null) { String localPath = null; + Long completionDate = null; if (file.local != null && file.local.isDownloadingCompleted) { localPath = file.local.path; + completionDate = System.currentTimeMillis(); } FileRecord.DownloadStatus downloadStatus = TdApiHelp.getDownloadStatus(file); DataVerticle.fileRepository.updateStatus(file.id, file.remote.uniqueId, localPath, - downloadStatus) + downloadStatus, + completionDate) .onSuccess(r -> { if (r == null || r.isEmpty()) return; sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject() .put("fileId", file.id) .put("downloadStatus", r.getString("downloadStatus")) .put("localPath", r.getString("localPath")) + .put("completionDate", r.getLong("completionDate")) )); }); } diff --git a/api/src/main/java/telegram/files/repository/Definition.java b/api/src/main/java/telegram/files/repository/Definition.java new file mode 100644 index 0000000..30ef4ba --- /dev/null +++ b/api/src/main/java/telegram/files/repository/Definition.java @@ -0,0 +1,47 @@ +package telegram.files.repository; + +import cn.hutool.core.lang.Version; +import cn.hutool.log.Log; +import cn.hutool.log.LogFactory; +import io.vertx.core.Future; +import io.vertx.sqlclient.SqlConnection; + +import java.util.TreeMap; +import java.util.stream.Stream; + +public interface Definition { + + Log log = LogFactory.get(); + + String getScheme(); + + default TreeMap getMigrations() { + return new TreeMap<>(); + } + + default Future createTable(SqlConnection conn) { + return conn + .query(getScheme()) + .execute() + .onFailure(err -> log.error("Failed to create table: %s".formatted(err.getMessage()))) + .mapEmpty(); + } + + default Future migrate(SqlConnection conn, Version lastVersion, Version currentVersion) { + TreeMap migrations = getMigrations(); + if (migrations.isEmpty()) { + return Future.succeededFuture(); + } + return Future.all(migrations.subMap(lastVersion, false, currentVersion, true).values() + .stream() + .flatMap(arr -> Stream.of(arr) + .map(sql -> conn.query(sql) + .execute() + .onFailure(e -> log.error("Failed to apply migration: %s".formatted(sql), e))) + ) + .toList() + ) + .onFailure(err -> log.error("Failed to migrate table: %s".formatted(err.getMessage()))) + .mapEmpty(); + } +} diff --git a/api/src/main/java/telegram/files/repository/FileRecord.java b/api/src/main/java/telegram/files/repository/FileRecord.java index 7c4c878..a740217 100644 --- a/api/src/main/java/telegram/files/repository/FileRecord.java +++ b/api/src/main/java/telegram/files/repository/FileRecord.java @@ -1,10 +1,14 @@ package telegram.files.repository; import cn.hutool.core.convert.Convert; +import cn.hutool.core.lang.Version; import cn.hutool.core.map.MapUtil; import io.vertx.sqlclient.templates.RowMapper; import io.vertx.sqlclient.templates.TupleMapper; +import java.util.Objects; +import java.util.TreeMap; + public record FileRecord(int id, //file id will change String uniqueId, // unique id of the file, if empty, it means the file is cant be downloaded long telegramId, @@ -20,7 +24,9 @@ public record FileRecord(int id, //file id will change String thumbnail, String caption, String localPath, - String downloadStatus // 'idle' | 'downloading' | 'paused' | 'completed' | 'error' + String downloadStatus, // 'idle' | 'downloading' | 'paused' | 'completed' | 'error' + long startDate, // date when the file was started to download + Long completionDate // date when the file was downloaded ) { public enum DownloadStatus { @@ -46,10 +52,31 @@ public record FileRecord(int id, //file id will change caption VARCHAR(255), local_path VARCHAR(255), download_status VARCHAR(255), + start_date BIGINT, + completion_date BIGINT, PRIMARY KEY (id, unique_id) ) """; + public static final TreeMap MIGRATIONS = new TreeMap<>(MapUtil.ofEntries( + MapUtil.entry(new Version("0.1.7"), new String[]{ + "ALTER TABLE file_record ADD COLUMN start_date BIGINT;", + "ALTER TABLE file_record ADD COLUMN completion_date BIGINT;", + }) + )); + + public static class FileRecordDefinition implements Definition { + @Override + public String getScheme() { + return SCHEME; + } + + @Override + public TreeMap getMigrations() { + return MIGRATIONS; + } + } + public static RowMapper ROW_MAPPER = row -> new FileRecord(row.getInteger("id"), row.getString("unique_id"), @@ -66,7 +93,9 @@ public record FileRecord(int id, //file id will change row.getString("thumbnail"), row.getString("caption"), row.getString("local_path"), - row.getString("download_status") + row.getString("download_status"), + Objects.requireNonNullElse(row.getLong("start_date"), 0L), + row.getLong("completion_date") ); public static TupleMapper PARAM_MAPPER = TupleMapper.mapper(r -> @@ -86,11 +115,13 @@ public record FileRecord(int id, //file id will change MapUtil.entry("thumbnail", r.thumbnail()), MapUtil.entry("caption", r.caption()), MapUtil.entry("local_path", r.localPath()), - MapUtil.entry("download_status", r.downloadStatus()) + MapUtil.entry("download_status", r.downloadStatus()), + MapUtil.entry("start_date", r.startDate()), + MapUtil.entry("completion_date", r.completionDate()) )); public FileRecord withSourceField(int id, long downloadedSize) { - return new FileRecord(id, uniqueId, telegramId, chatId, messageId, date, hasSensitiveContent, size, downloadedSize, type, mimeType, fileName, thumbnail, caption, localPath, downloadStatus); + return new FileRecord(id, uniqueId, telegramId, chatId, messageId, date, hasSensitiveContent, size, downloadedSize, type, mimeType, fileName, thumbnail, caption, localPath, downloadStatus, startDate, completionDate); } } diff --git a/api/src/main/java/telegram/files/repository/FileRepository.java b/api/src/main/java/telegram/files/repository/FileRepository.java index c25f10b..9918f0f 100644 --- a/api/src/main/java/telegram/files/repository/FileRepository.java +++ b/api/src/main/java/telegram/files/repository/FileRepository.java @@ -2,6 +2,7 @@ package telegram.files.repository; import io.vertx.core.Future; import io.vertx.core.MultiMap; +import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import org.jooq.lambda.tuple.Tuple3; @@ -9,8 +10,6 @@ import java.util.List; import java.util.Map; public interface FileRepository { - Future init(); - Future create(FileRecord fileRecord); Future> getFiles(long chatId, List fileIds); @@ -25,15 +24,17 @@ public interface FileRepository { Future getDownloadStatistics(long telegramId); + Future getCompletedRangeStatistics(long id, long startTime, long endTime, int timeRange); + Future countByStatus(long telegramId, FileRecord.DownloadStatus downloadStatus); Future updateStatus(int fileId, String uniqueId, String localPath, - FileRecord.DownloadStatus downloadStatus); + FileRecord.DownloadStatus downloadStatus, + Long completionDate); Future updateFileId(int fileId, String uniqueId); Future deleteByUniqueId(String uniqueId); - } diff --git a/api/src/main/java/telegram/files/repository/SettingKey.java b/api/src/main/java/telegram/files/repository/SettingKey.java index db3ff6a..7f1c50d 100644 --- a/api/src/main/java/telegram/files/repository/SettingKey.java +++ b/api/src/main/java/telegram/files/repository/SettingKey.java @@ -1,11 +1,13 @@ package telegram.files.repository; import cn.hutool.core.convert.Convert; +import cn.hutool.core.lang.Version; import io.vertx.core.json.JsonObject; import java.util.function.Function; public enum SettingKey { + version(Version::new), uniqueOnly(Convert::toBool, false), needToLoadImages(Convert::toBool, false), imageLoadSize, @@ -17,6 +19,10 @@ public enum SettingKey { */ autoDownloadLimit(Convert::toInt), proxys(value -> new JsonObject(value).mapTo(SettingProxyRecords.class)), + /** + * Interval for calculating average speed, in seconds + */ + avgSpeedInterval(Convert::toInt, 5 * 60), ; public final Function converter; diff --git a/api/src/main/java/telegram/files/repository/SettingRecord.java b/api/src/main/java/telegram/files/repository/SettingRecord.java index 345f3b1..149d367 100644 --- a/api/src/main/java/telegram/files/repository/SettingRecord.java +++ b/api/src/main/java/telegram/files/repository/SettingRecord.java @@ -15,6 +15,13 @@ public record SettingRecord(String key, String value) { ) """; + public static class SettingRecordDefinition implements Definition { + @Override + public String getScheme() { + return SCHEME; + } + } + public static RowMapper ROW_MAPPER = row -> new SettingRecord(row.getString("key"), row.getString("value") diff --git a/api/src/main/java/telegram/files/repository/SettingRepository.java b/api/src/main/java/telegram/files/repository/SettingRepository.java index 64b96a9..898b439 100644 --- a/api/src/main/java/telegram/files/repository/SettingRepository.java +++ b/api/src/main/java/telegram/files/repository/SettingRepository.java @@ -5,9 +5,6 @@ import io.vertx.core.Future; import java.util.List; public interface SettingRepository { - - Future init(); - Future createOrUpdate(String key, String value); Future> getByKeys(List keys); diff --git a/api/src/main/java/telegram/files/repository/StatisticRecord.java b/api/src/main/java/telegram/files/repository/StatisticRecord.java new file mode 100644 index 0000000..1300b11 --- /dev/null +++ b/api/src/main/java/telegram/files/repository/StatisticRecord.java @@ -0,0 +1,49 @@ +package telegram.files.repository; + +import cn.hutool.core.map.MapUtil; +import io.vertx.sqlclient.templates.RowMapper; +import io.vertx.sqlclient.templates.TupleMapper; + +public record StatisticRecord( + String relatedId, + Type type, + long timestamp, + String data) { + + public enum Type { + speed, + + ; + } + + public static final String SCHEME = """ + CREATE TABLE IF NOT EXISTS statistic_record + ( + related_id VARCHAR(255), + type VARCHAR(255), + timestamp BIGINT, + data TEXT + ) + """; + + public static class StatisticRecordDefinition implements Definition { + @Override + public String getScheme() { + return SCHEME; + } + } + + public static RowMapper ROW_MAPPER = row -> + new StatisticRecord(row.getString("related_id"), + Type.valueOf(row.getString("type")), + row.getLong("timestamp"), + row.getString("data") + ); + + public static TupleMapper PARAM_MAPPER = TupleMapper.mapper(r -> + MapUtil.ofEntries(MapUtil.entry("related_id", r.relatedId), + MapUtil.entry("type", r.type().name()), + MapUtil.entry("timestamp", r.timestamp()), + MapUtil.entry("data", r.data()) + )); +} diff --git a/api/src/main/java/telegram/files/repository/StatisticRepository.java b/api/src/main/java/telegram/files/repository/StatisticRepository.java new file mode 100644 index 0000000..e3dc193 --- /dev/null +++ b/api/src/main/java/telegram/files/repository/StatisticRepository.java @@ -0,0 +1,14 @@ +package telegram.files.repository; + +import io.vertx.core.Future; + +import java.util.List; + +public interface StatisticRepository { + Future create(StatisticRecord record); + + Future> getRangeStatistics(StatisticRecord.Type type, + long relatedId, + long startTime, + long endTime); +} diff --git a/api/src/main/java/telegram/files/repository/TelegramRecord.java b/api/src/main/java/telegram/files/repository/TelegramRecord.java index 393aa1f..337a8f2 100644 --- a/api/src/main/java/telegram/files/repository/TelegramRecord.java +++ b/api/src/main/java/telegram/files/repository/TelegramRecord.java @@ -21,6 +21,13 @@ public record TelegramRecord( ) """; + public static class TelegramRecordDefinition implements Definition { + @Override + public String getScheme() { + return SCHEME; + } + } + public static RowMapper ROW_MAPPER = row -> new TelegramRecord(row.getLong("id"), row.getString("first_name"), diff --git a/api/src/main/java/telegram/files/repository/TelegramRepository.java b/api/src/main/java/telegram/files/repository/TelegramRepository.java index fc46007..8edec99 100644 --- a/api/src/main/java/telegram/files/repository/TelegramRepository.java +++ b/api/src/main/java/telegram/files/repository/TelegramRepository.java @@ -5,9 +5,6 @@ import io.vertx.core.Future; import java.util.List; public interface TelegramRepository { - - Future init(); - String getRootPath(); Future create(TelegramRecord telegramRecord); diff --git a/api/src/main/java/telegram/files/repository/impl/FileRepositoryImpl.java b/api/src/main/java/telegram/files/repository/impl/FileRepositoryImpl.java index a4806ce..ba17284 100644 --- a/api/src/main/java/telegram/files/repository/impl/FileRepositoryImpl.java +++ b/api/src/main/java/telegram/files/repository/impl/FileRepositoryImpl.java @@ -3,17 +3,21 @@ package telegram.files.repository.impl; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.collection.IterUtil; import cn.hutool.core.convert.Convert; +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.DateUtil; import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.log.Log; import cn.hutool.log.LogFactory; import io.vertx.core.Future; import io.vertx.core.MultiMap; +import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.jdbcclient.JDBCPool; import io.vertx.sqlclient.templates.SqlTemplate; import org.jooq.lambda.tuple.Tuple; import org.jooq.lambda.tuple.Tuple3; +import telegram.files.MessyUtils; import telegram.files.repository.FileRecord; import telegram.files.repository.FileRepository; @@ -34,19 +38,6 @@ public class FileRepositoryImpl implements FileRepository { this.pool = pool; } - @Override - public Future init() { - return pool.getConnection() - .compose(conn -> conn - .query(FileRecord.SCHEME) - .execute() - .onComplete(r -> conn.close()) - .onFailure(err -> log.error("Failed to create table file_record: %s".formatted(err.getMessage()))) - .onSuccess(ps -> log.trace("Successfully created table: file_record")) - ) - .mapEmpty(); - } - @Override public Future create(FileRecord fileRecord) { return SqlTemplate @@ -54,9 +45,9 @@ public class FileRepositoryImpl implements FileRepository { INSERT INTO file_record(id, unique_id, telegram_id, chat_id, message_id, date, has_sensitive_content, size, downloaded_size, type, mime_type, file_name, thumbnail, caption, local_path, - download_status) + download_status, start_date) values (#{id}, #{unique_id}, #{telegram_id}, #{chat_id}, #{message_id}, #{date}, #{has_sensitive_content}, #{size}, #{downloaded_size}, #{type}, - #{mime_type}, #{file_name}, #{thumbnail}, #{caption}, #{local_path}, #{download_status}) + #{mime_type}, #{file_name}, #{thumbnail}, #{caption}, #{local_path}, #{download_status}, #{start_date}) """) .mapFrom(FileRecord.PARAM_MAPPER) .execute(fileRecord) @@ -235,6 +226,61 @@ public class FileRepositoryImpl implements FileRepository { .onFailure(err -> log.error("Failed to get download statistics: %s".formatted(err.getMessage()))); } + @Override + public Future getCompletedRangeStatistics(long telegramId, long startTime, long endTime, int timeRange) { + return SqlTemplate + .forQuery(pool, """ + SELECT strftime( + CASE + WHEN #{timeRange} = 1 THEN '%Y-%m-%d %H:%M' + WHEN #{timeRange} = 2 THEN '%Y-%m-%d %H:00' + WHEN #{timeRange} IN (3, 4) THEN '%Y-%m-%d' + END, + datetime(completion_date / 1000, 'unixepoch') + ) AS time, + COUNT(*) AS total + FROM file_record + WHERE telegram_id = #{telegramId} + AND completion_date IS NOT NULL + AND completion_date >= #{startTime} + AND completion_date <= #{endTime} + GROUP BY time + ORDER BY time; + """) + .mapTo(row -> new JsonObject() + .put("time", row.getString("time")) + .put("total", row.getInteger("total")) + ) + .execute(Map.of("telegramId", telegramId, "startTime", startTime, "endTime", endTime, "timeRange", timeRange)) + .map(IterUtil::toList) + .map(rs -> { + if (CollUtil.isEmpty(rs)) { + return JsonArray.of(); + } + if (timeRange == 1) { + // Statistics grouped by five minutes + return rs.stream() + .peek(c -> c.put("time", MessyUtils.withGrouping5Minutes( + DateUtil.toLocalDateTime(DateUtil.date(Convert.toLong(c.getString("time"), 0L))) + ).format(DatePattern.NORM_DATETIME_MINUTE_FORMATTER))) + .collect(Collectors.groupingBy(c -> c.getString("time"), + Collectors.summingInt(c -> c.getInteger("total")) + )) + .entrySet().stream() + .map(e -> new JsonObject() + .put("time", e.getKey()) + .put("total", e.getValue()) + ) + .collect(JsonArray::new, JsonArray::add, JsonArray::addAll); + } else { + JsonArray jsonArray = new JsonArray(); + rs.forEach(jsonArray::add); + return jsonArray; + } + }) + .onFailure(err -> log.error("Failed to get completed statistics: %s".formatted(err.getMessage()))); + } + @Override public Future countByStatus(long telegramId, FileRecord.DownloadStatus downloadStatus) { return SqlTemplate @@ -248,7 +294,11 @@ public class FileRepositoryImpl implements FileRepository { } @Override - public Future updateStatus(int fileId, String uniqueId, String localPath, FileRecord.DownloadStatus downloadStatus) { + public Future updateStatus(int fileId, + String uniqueId, + String localPath, + FileRecord.DownloadStatus downloadStatus, + Long completionDate) { if (StrUtil.isBlank(localPath) && downloadStatus == null) { return Future.succeededFuture(null); } @@ -265,13 +315,16 @@ public class FileRepositoryImpl implements FileRepository { return SqlTemplate .forUpdate(pool, """ - UPDATE file_record SET local_path = #{localPath}, download_status = #{downloadStatus} + UPDATE file_record SET local_path = #{localPath}, + download_status = #{downloadStatus}, + completion_date = #{completionDate} WHERE id = #{fileId} AND unique_id = #{uniqueId} """) .execute(MapUtil.ofEntries(MapUtil.entry("fileId", fileId), MapUtil.entry("uniqueId", uniqueId), MapUtil.entry("localPath", pathUpdated ? localPath : record.localPath()), - MapUtil.entry("downloadStatus", downloadStatusUpdated ? downloadStatus.name() : record.downloadStatus()) + MapUtil.entry("downloadStatus", downloadStatusUpdated ? downloadStatus.name() : record.downloadStatus()), + MapUtil.entry("completionDate", completionDate) )) .onFailure(err -> log.error("Failed to update file record: %s".formatted(err.getMessage())) @@ -280,6 +333,7 @@ public class FileRepositoryImpl implements FileRepository { JsonObject result = JsonObject.of(); if (pathUpdated) { result.put("localPath", localPath); + result.put("completionDate", completionDate); } if (downloadStatusUpdated) { result.put("downloadStatus", downloadStatus.name()); diff --git a/api/src/main/java/telegram/files/repository/impl/SettingRepositoryImpl.java b/api/src/main/java/telegram/files/repository/impl/SettingRepositoryImpl.java index 055d5be..4e431f5 100644 --- a/api/src/main/java/telegram/files/repository/impl/SettingRepositoryImpl.java +++ b/api/src/main/java/telegram/files/repository/impl/SettingRepositoryImpl.java @@ -27,19 +27,6 @@ public class SettingRepositoryImpl implements SettingRepository { this.pool = pool; } - @Override - public Future init() { - return pool.getConnection() - .compose(conn -> conn - .query(SettingRecord.SCHEME) - .execute() - .onComplete(r -> conn.close()) - .onFailure(err -> log.error("Failed to create table setting_record: %s".formatted(err.getMessage()))) - .onSuccess(ps -> log.trace("Successfully created table: setting_record")) - ) - .mapEmpty(); - } - @Override public Future createOrUpdate(String key, String value) { return SqlTemplate diff --git a/api/src/main/java/telegram/files/repository/impl/StatisticRepositoryImpl.java b/api/src/main/java/telegram/files/repository/impl/StatisticRepositoryImpl.java new file mode 100644 index 0000000..55c6842 --- /dev/null +++ b/api/src/main/java/telegram/files/repository/impl/StatisticRepositoryImpl.java @@ -0,0 +1,68 @@ +package telegram.files.repository.impl; + +import cn.hutool.core.collection.IterUtil; +import cn.hutool.log.Log; +import cn.hutool.log.LogFactory; +import io.vertx.core.Future; +import io.vertx.jdbcclient.JDBCPool; +import io.vertx.sqlclient.templates.SqlTemplate; +import telegram.files.repository.StatisticRecord; +import telegram.files.repository.StatisticRepository; + +import java.util.List; +import java.util.Map; + +public class StatisticRepositoryImpl implements StatisticRepository { + + private static final Log log = LogFactory.get(); + + private final JDBCPool pool; + + public StatisticRepositoryImpl(JDBCPool pool) { + this.pool = pool; + } + + @Override + public Future create(StatisticRecord record) { + return SqlTemplate + .forUpdate(pool, """ + INSERT INTO statistic_record(related_id, type, timestamp, data) + VALUES (#{related_id}, #{type}, #{timestamp}, #{data}) + """) + .mapFrom(StatisticRecord.PARAM_MAPPER) + .execute(record) + .onSuccess(r -> log.trace("Successfully created statistic record: %s".formatted(record.relatedId()))) + .onFailure( + err -> log.error("Failed to create statistic record: %s".formatted(err.getMessage())) + ) + .mapEmpty(); + } + + @Override + public Future> getRangeStatistics(StatisticRecord.Type type, + long relatedId, + long startTime, + long endTime) { + return SqlTemplate + .forQuery(pool, """ + SELECT * + FROM statistic_record + WHERE type = #{type} + AND related_id = #{relatedId} + AND timestamp >= #{startTime} + AND timestamp <= #{endTime} + ORDER BY timestamp + """) + .mapTo(StatisticRecord.ROW_MAPPER) + .execute(Map.of( + "type", type.name(), + "relatedId", relatedId, + "startTime", startTime, + "endTime", endTime + )) + .map(IterUtil::toList) + .onFailure( + err -> log.error("Failed to get range statistics: %s".formatted(err.getMessage())) + ); + } +} diff --git a/api/src/main/java/telegram/files/repository/impl/TelegramRepositoryImpl.java b/api/src/main/java/telegram/files/repository/impl/TelegramRepositoryImpl.java index 88c2477..1e0bf39 100644 --- a/api/src/main/java/telegram/files/repository/impl/TelegramRepositoryImpl.java +++ b/api/src/main/java/telegram/files/repository/impl/TelegramRepositoryImpl.java @@ -26,19 +26,6 @@ public class TelegramRepositoryImpl implements TelegramRepository { this.pool = pool; } - @Override - public Future init() { - return pool.getConnection() - .compose(conn -> conn - .query(TelegramRecord.SCHEME) - .execute() - .onComplete(r -> conn.close()) - .onFailure(err -> log.error("Failed to create table telegram_record: %s".formatted(err.getMessage()))) - .onSuccess(ps -> log.trace("Successfully created table: telegram_record")) - ) - .mapEmpty(); - } - @Override public String getRootPath() { return Config.TELEGRAM_ROOT + File.separator + UUID.randomUUID(); diff --git a/api/src/test/java/telegram/files/DataVerticleTest.java b/api/src/test/java/telegram/files/DataVerticleTest.java index d67a3f9..d6c062b 100644 --- a/api/src/test/java/telegram/files/DataVerticleTest.java +++ b/api/src/test/java/telegram/files/DataVerticleTest.java @@ -1,6 +1,7 @@ package telegram.files; import cn.hutool.core.io.FileUtil; +import cn.hutool.core.lang.Version; import cn.hutool.log.Log; import cn.hutool.log.LogFactory; import io.vertx.core.Vertx; @@ -9,6 +10,7 @@ import io.vertx.junit5.VertxTestContext; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import telegram.files.repository.FileRecord; +import telegram.files.repository.SettingKey; import telegram.files.repository.TelegramRecord; @ExtendWith(VertxExtension.class) @@ -22,8 +24,8 @@ public class DataVerticleTest { log.debug("DATA_PATH:" + DataVerticle.getDataPath()); } - @AfterAll - static void tearDown() { + @AfterEach + void tearDown() { String dataPath = DataVerticle.getDataPath(); if (FileUtil.file(dataPath).exists()) { FileUtil.del(dataPath); @@ -37,6 +39,17 @@ public class DataVerticleTest { .onComplete(testContext.succeedingThenComplete()); } + @Test + @DisplayName("Check table initialization") + void checkTableInitialization(Vertx vertx, VertxTestContext testContext) { + DataVerticle.settingRepository.getByKey(SettingKey.version) + .onComplete(testContext.succeeding(r -> testContext.verify(() -> { + Assertions.assertNotNull(r); + Assertions.assertEquals(new Version(Start.VERSION), r); + testContext.completeNow(); + }))); + } + @Test @DisplayName("Test Create telegram record") void createTelegramRecordTest(Vertx vertx, VertxTestContext testContext) { @@ -69,7 +82,7 @@ public class DataVerticleTest { @DisplayName("Test Get file record by primary key") void getFileRecordByPrimaryKeyTest(Vertx vertx, VertxTestContext testContext) { FileRecord fileRecord = new FileRecord( - 1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", "local_path", "download_status" + 1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", "local_path", "download_status", 0, null ); DataVerticle.fileRepository.create(fileRecord) .compose(r -> DataVerticle.fileRepository.getByPrimaryKey(r.id(), r.uniqueId())) @@ -83,15 +96,17 @@ public class DataVerticleTest { @DisplayName("Test update file status") void updateFileStatusTest(Vertx vertx, VertxTestContext testContext) { FileRecord fileRecord = new FileRecord( - 1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, "download_status" + 1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, "download_status", 0, null ); String updateLocalPath = "local_path"; + Long completionDate = 1L; DataVerticle.fileRepository.create(fileRecord) - .compose(r -> DataVerticle.fileRepository.updateStatus(r.id(), r.uniqueId(), updateLocalPath, FileRecord.DownloadStatus.downloading)) + .compose(r -> DataVerticle.fileRepository.updateStatus(r.id(), r.uniqueId(), updateLocalPath, FileRecord.DownloadStatus.downloading, completionDate)) .compose(r -> { testContext.verify(() -> { Assertions.assertEquals(updateLocalPath, r.getString("localPath")); Assertions.assertEquals(FileRecord.DownloadStatus.downloading.name(), r.getString("downloadStatus")); + Assertions.assertEquals(completionDate, r.getLong("completionDate")); }); return DataVerticle.fileRepository.getByPrimaryKey(fileRecord.id(), fileRecord.uniqueId()); }) diff --git a/request.http b/request.http index 321abdf..c381fa0 100644 --- a/request.http +++ b/request.http @@ -77,7 +77,7 @@ GET http://{{server}}//file/preview?chatId={{chatId}}&messageId={{messageId}} Cookie: {{tf}} ### Get download statistics -GET http://{{server}}/telegram/{{telegramId}}/download-statistics +GET http://{{server}}/telegram/{{telegramId}}/download-statistics?type=phase&timeRange=3 Cookie: {{tf}} ### Telegram GetMe diff --git a/web/package-lock.json b/web/package-lock.json index e88e901..95aa2f5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "telegram-files-web", - "version": "0.1.4", + "version": "0.1.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "telegram-files-web", - "version": "0.1.4", + "version": "0.1.6", "dependencies": { "@dnd-kit/core": "^6.2.0", "@dnd-kit/modifiers": "^8.0.0", @@ -35,12 +35,14 @@ "input-otp": "^1.4.1", "lottie-react": "^2.4.0", "lucide-react": "^0.462.0", + "motion": "^11.15.0", "next": "^15.0.1", "pretty-bytes": "^6.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", "react-use-websocket": "^4.11.1", + "recharts": "^2.15.0", "spoiled": "^0.3.2", "swr": "^2.2.5", "tailwind-merge": "^2.5.5", @@ -2160,6 +2162,60 @@ } } }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "node_modules/@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", @@ -3364,8 +3420,117 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -3450,6 +3615,11 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3526,6 +3696,15 @@ "node": ">=6.0.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4210,12 +4389,25 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-equals": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.0.tgz", + "integrity": "sha512-3VpaQYf+CDFdRQfgsb+3vY7XaKjM35WCMoQTTE8h4S/eUkHzyJFOOA/gATYgoLejy4FBrEQD/sXe5Auk4cW/AQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -4752,6 +4944,14 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -5313,6 +5513,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5411,6 +5616,31 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion": { + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-11.15.0.tgz", + "integrity": "sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw==", + "dependencies": { + "framer-motion": "^11.15.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/motion-dom": { "version": "11.14.3", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz", @@ -6045,7 +6275,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -6121,8 +6350,7 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/react-remove-scroll": { "version": "2.6.0", @@ -6169,6 +6397,20 @@ } } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -6190,6 +6432,21 @@ } } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/react-use-websocket": { "version": "4.11.1", "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.11.1.tgz", @@ -6214,6 +6471,41 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz", + "integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.0", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.7.tgz", @@ -6982,6 +7274,11 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7245,6 +7542,27 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8597,6 +8915,60 @@ "@t3-oss/env-core": "0.10.1" } }, + "@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "requires": { + "@types/d3-color": "*" + } + }, + "@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==" + }, + "@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "requires": { + "@types/d3-time": "*" + } + }, + "@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "requires": { + "@types/d3-path": "*" + } + }, + "@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", @@ -9353,8 +9725,84 @@ "csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "requires": { + "internmap": "1 - 2" + } + }, + "d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==" + }, + "d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "requires": { + "d3-color": "1 - 3" + } + }, + "d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" + }, + "d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "requires": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + } + }, + "d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "requires": { + "d3-path": "^3.1.0" + } + }, + "d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "requires": { + "d3-array": "2 - 3" + } + }, + "d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "requires": { + "d3-time": "1 - 3" + } + }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" }, "damerau-levenshtein": { "version": "1.0.8", @@ -9409,6 +9857,11 @@ "ms": "^2.1.3" } }, + "decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -9467,6 +9920,15 @@ "esutils": "^2.0.2" } }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -9989,12 +10451,22 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "fast-equals": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.0.tgz", + "integrity": "sha512-3VpaQYf+CDFdRQfgsb+3vY7XaKjM35WCMoQTTE8h4S/eUkHzyJFOOA/gATYgoLejy4FBrEQD/sXe5Auk4cW/AQ==" + }, "fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -10376,6 +10848,11 @@ "side-channel": "^1.0.4" } }, + "internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==" + }, "is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -10758,6 +11235,11 @@ "p-locate": "^5.0.0" } }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10829,6 +11311,15 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" }, + "motion": { + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-11.15.0.tgz", + "integrity": "sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw==", + "requires": { + "framer-motion": "^11.15.0", + "tslib": "^2.4.0" + } + }, "motion-dom": { "version": "11.14.3", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz", @@ -11175,7 +11666,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -11219,8 +11709,7 @@ "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "react-remove-scroll": { "version": "2.6.0", @@ -11243,6 +11732,16 @@ "tslib": "^2.0.0" } }, + "react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "requires": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + } + }, "react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -11252,6 +11751,17 @@ "tslib": "^2.0.0" } }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, "react-use-websocket": { "version": "4.11.1", "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.11.1.tgz", @@ -11273,6 +11783,36 @@ "picomatch": "^2.2.1" } }, + "recharts": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz", + "integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==", + "requires": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.0", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "dependencies": { + "react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + } + } + }, + "recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "requires": { + "decimal.js-light": "^2.4.1" + } + }, "reflect.getprototypeof": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.7.tgz", @@ -11790,6 +12330,11 @@ "thenify": ">= 3.1.0 < 4" } }, + "tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11970,6 +12515,27 @@ "@radix-ui/react-dialog": "^1.1.1" } }, + "victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "requires": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/web/package.json b/web/package.json index 5dc8c62..92395bd 100644 --- a/web/package.json +++ b/web/package.json @@ -43,12 +43,14 @@ "input-otp": "^1.4.1", "lottie-react": "^2.4.0", "lucide-react": "^0.462.0", + "motion": "^11.15.0", "next": "^15.0.1", "pretty-bytes": "^6.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", "react-use-websocket": "^4.11.1", + "recharts": "^2.15.0", "spoiled": "^0.3.2", "swr": "^2.2.5", "tailwind-merge": "^2.5.5", diff --git a/web/src/components/file-card.tsx b/web/src/components/file-card.tsx index 3d2e333..4ca80b5 100644 --- a/web/src/components/file-card.tsx +++ b/web/src/components/file-card.tsx @@ -4,6 +4,8 @@ import { Card, CardContent } from "@/components/ui/card"; import { type TelegramFile } from "@/lib/types"; import { Calendar, + Clock10, + ClockArrowDown, Download, FileAudio2Icon, FileIcon, @@ -183,7 +185,7 @@ function FileDrawer({
- Date + Received At
{formatDistanceToNow(new Date(file.date * 1000), { @@ -201,6 +203,34 @@ function FileDrawer({ {prettyBytes(file.downloadedSize)}
+ + {(file.downloadStatus !== 'idle' && file.startDate !== 0) && ( +
+
+ + Download At +
+ + {formatDistanceToNow(new Date(file.startDate), { + addSuffix: true, + })} + +
+ )} + + {file.completionDate && ( +
+
+ + Completion At +
+ + {formatDistanceToNow(new Date(file.completionDate), { + addSuffix: true, + })} + +
+ )} diff --git a/web/src/components/file-extra.tsx b/web/src/components/file-extra.tsx index f6d9bbb..5dfd253 100644 --- a/web/src/components/file-extra.tsx +++ b/web/src/components/file-extra.tsx @@ -1,4 +1,4 @@ -import { Captions, Clock, Copy, FileCheck } from "lucide-react"; +import { Captions, Clock, ClockArrowDown, Copy, FileCheck } from "lucide-react"; import SpoiledWrapper from "@/components/spoiled-wrapper"; import { Tooltip, @@ -63,9 +63,9 @@ export default function FileExtra({ file, rowHeight }: FileExtraProps) { )} -
+
- +

@@ -81,6 +81,25 @@ export default function FileExtra({ file, rowHeight }: FileExtraProps) {

+ {file.completionDate && ( + + +

+ + + {formatDistanceToNow(new Date(file.completionDate), { + addSuffix: true, + })} + +

+
+ +
+ {`File downloaded at ${new Date(file.completionDate).toLocaleString()}`} +
+
+
+ )}
diff --git a/web/src/components/file-phase-statistics.tsx b/web/src/components/file-phase-statistics.tsx new file mode 100644 index 0000000..546bb65 --- /dev/null +++ b/web/src/components/file-phase-statistics.tsx @@ -0,0 +1,249 @@ +import React, { useState } from "react"; +import useSWR from "swr"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { request } from "@/lib/api"; +import prettyBytes from "pretty-bytes"; // Type definitions + +// Type definitions +type TimeRange = "1" | "2" | "3" | "4"; + +interface SpeedData { + avgSpeed: number; + medianSpeed: number; + maxSpeed: number; + minSpeed: number; +} + +interface SpeedStats { + time: string; + data: SpeedData; +} + +interface CompletedStats { + time: string; + total: number; +} + +interface ApiResponse { + speedStats: SpeedStats[]; + completedStats: CompletedStats[]; +} + +const formatDate = (dateStr: string, timeRange: TimeRange): string => { + const date = new Date(dateStr); + + switch (timeRange) { + case "1": // Last hour + return date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + }); + case "2": // Last day + return date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + }); + case "3": // Last week + case "4": // Last month + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + } +}; + +interface TelegramStatsProps { + telegramId: string; +} + +const timeRangeOptions = [ + { value: "1", label: "1 Hour" }, + { value: "2", label: "24 Hours" }, + { value: "3", label: "1 Week" }, + { value: "4", label: "30 Days" }, +]; + +const axisStyle = { + fontSize: 11, + fill: "#6b7280", // text-gray-500 +}; + +const TelegramStats: React.FC = ({ telegramId }) => { + const [timeRange, setTimeRange] = useState("2"); + + const { data, error, isLoading } = useSWR( + `/telegram/${telegramId}/download-statistics?type=phase&timeRange=${timeRange}`, + request, + ); + + if (error) { + return
Failed to load statistics
; + } + + if (isLoading || !data) { + return
Loading statistics...
; + } + + // Transform speed data for the chart + const speedChartData = data.speedStats.map((stat) => ({ + time: formatDate(stat.time, timeRange), + "Average Speed": stat.data.avgSpeed, + "Median Speed": stat.data.medianSpeed, + "Max Speed": stat.data.maxSpeed, + "Min Speed": stat.data.minSpeed, + })); + + // Transform completion data for the chart + const completionChartData = data.completedStats.map((stat) => ({ + time: formatDate(stat.time, timeRange), + "Completed Downloads": stat.total, + })); + + return ( +
+
+ +
+ + + + Download Speed Over Time + + +
+ + + + + + prettyBytes(value, { bits: true }) + } + tick={axisStyle} + interval="preserveStartEnd" + /> + + prettyBytes(value, { bits: true }) + } + contentStyle={{ + backgroundColor: "rgba(255, 255, 255, 0.9)", + border: "none", + borderRadius: "6px", + boxShadow: "0 2px 4px rgba(0,0,0,0.1)", + }} + /> + + + + + + + +
+
+
+ + + + Completed Downloads Over Time + + +
+ + + + + + + + + + +
+
+
+
+ ); +}; + +export default TelegramStats; diff --git a/web/src/components/settings-dialog.tsx b/web/src/components/settings-dialog.tsx index 32ff1dc..e70eb13 100644 --- a/web/src/components/settings-dialog.tsx +++ b/web/src/components/settings-dialog.tsx @@ -14,6 +14,9 @@ import { useTelegramAccount } from "@/hooks/use-telegram-account"; import Proxys from "@/components/proxys"; import SettingsForm from "@/components/settings-form"; import About from "@/components/about"; +import { ChartColumnIncreasingIcon } from "@/components/ui/chart-column-increasing"; +import { LayoutPanelTopIcon } from "@/components/ui/layout-panel-top"; +import FilePhaseStatistics from "@/components/file-phase-statistics"; export const SettingsDialog: React.FC = () => { const [isOpen, setIsOpen] = useState(false); @@ -53,9 +56,24 @@ export const SettingsDialog: React.FC = () => { -
+
{accountId ? ( - + + + + + + + + + + + + + + + + ) : (

diff --git a/web/src/components/ui/chart-column-increasing.tsx b/web/src/components/ui/chart-column-increasing.tsx new file mode 100644 index 0000000..4584d6f --- /dev/null +++ b/web/src/components/ui/chart-column-increasing.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { motion, useAnimation, type Variants } from "motion/react"; + +const frameVariants: Variants = { + visible: { opacity: 1 }, + hidden: { opacity: 1 }, +}; + +const lineVariants: Variants = { + visible: { pathLength: 1, opacity: 1 }, + hidden: { pathLength: 0, opacity: 0 }, +}; + +const ChartColumnIncreasingIcon = () => { + const controls = useAnimation(); + + const handleHoverStart = async () => { + await controls.start((i) => ({ + pathLength: 0, + opacity: 0, + transition: { delay: i * 0.1, duration: 0.3 }, + })); + await controls.start((i) => ({ + pathLength: 1, + opacity: 1, + transition: { delay: i * 0.1, duration: 0.3 }, + })); + }; + + const handleHoverEnd = () => { + void controls.start("visible"); + }; + + return ( +

+ + + + + + +
+ ); +}; + +export { ChartColumnIncreasingIcon }; diff --git a/web/src/components/ui/chart.tsx b/web/src/components/ui/chart.tsx new file mode 100644 index 0000000..a5086cb --- /dev/null +++ b/web/src/components/ui/chart.tsx @@ -0,0 +1,365 @@ +"use client"; +/* eslint-disable */ +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +import { cn } from "@/lib/utils"; // Format: { THEME_NAME: CSS_SELECTOR } + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +}); +ChartContainer.displayName = "Chart"; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +