From d30d58653ed921043d384227e97753d492a0c10a Mon Sep 17 00:00:00 2001 From: Doonut Date: Wed, 24 Dec 2025 20:56:42 -0800 Subject: [PATCH 1/4] main --- backend/Cargo.lock | 1 + backend/Cargo.toml | 1 + backend/src/api_doc.rs | 4 +- .../src/handlers/user_stats/get_user_stats.rs | 100 +++++++++-- backend/src/repositories/user_stats.rs | 8 +- frontend/package-lock.json | 17 ++ .../user-stats/IndexerStatsEvolutions.vue | 65 ++++--- .../user-stats/IndexerStatsIncreases.vue | 18 +- .../src/components/user-stats/SearchForm.vue | 168 ++++++++++++++---- frontend/src/services/api/userStatsService.ts | 18 +- frontend/src/utils/trackerColors.ts | 31 ++++ frontend/src/views/HomeView.vue | 11 +- 12 files changed, 351 insertions(+), 91 deletions(-) create mode 100644 frontend/src/utils/trackerColors.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f42b975..91bfb64 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -623,6 +623,7 @@ dependencies = [ "thiserror", "tokio", "tokio-cron-scheduler", + "url", "utoipa", "utoipa-actix-web", "utoipa-swagger-ui", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ea941c2..c032f96 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -21,6 +21,7 @@ actix-cors = "0.7.1" sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-native-tls", "postgres", "chrono" ] } serde = { version = "1.0", features = ["derive"] } serde_json = {version = "1.0", features = ["preserve_order"]} +url = "2.5" chrono = { version = "0.4", features = ["serde"] } log = "0.4" futures = "0.3" diff --git a/backend/src/api_doc.rs b/backend/src/api_doc.rs index 9cc7e38..0596ff0 100644 --- a/backend/src/api_doc.rs +++ b/backend/src/api_doc.rs @@ -1,6 +1,6 @@ use utoipa::OpenApi; -use crate::handlers::user_stats::get_user_stats::GetUserStatsQuery; +use crate::handlers::user_stats::get_user_stats::GetUserStatsQueryParams; #[derive(OpenApi)] #[openapi( @@ -15,6 +15,6 @@ use crate::handlers::user_stats::get_user_stats::GetUserStatsQuery; crate::handlers::user_stats::get_user_stats::exec, crate::handlers::user_stats::get_user_stats_prometheus::exec, ), - components(schemas(GetUserStatsQuery),) + components(schemas(GetUserStatsQueryParams),) )] pub struct ApiDoc; diff --git a/backend/src/handlers/user_stats/get_user_stats.rs b/backend/src/handlers/user_stats/get_user_stats.rs index d6492e1..951294a 100644 --- a/backend/src/handlers/user_stats/get_user_stats.rs +++ b/backend/src/handlers/user_stats/get_user_stats.rs @@ -1,38 +1,108 @@ -use crate::{Dasharr, error::Result, models::user_stats::UserProfileVec}; -use actix_web::{ - HttpResponse, - web::{Data, Query}, -}; +use crate::{Dasharr, models::user_stats::UserProfileVec}; +use actix_web::web::Data; +use actix_web::{HttpRequest, HttpResponse}; use chrono::NaiveDateTime; use serde::Deserialize; +use std::collections::HashMap; use utoipa::{IntoParams, ToSchema}; #[derive(Debug, Deserialize, IntoParams, ToSchema)] -pub struct GetUserStatsQuery { - pub indexer_id: i64, +pub struct GetUserStatsQueryParams { #[param(value_type = String, format = DateTime)] pub date_from: NaiveDateTime, #[param(value_type = String, format = DateTime)] pub date_to: NaiveDateTime, } +#[derive(Debug)] +struct GetUserStatsQuery { + pub indexer_ids: Vec, + pub date_from: NaiveDateTime, + pub date_to: NaiveDateTime, +} + +fn parse_query_string( + req: &HttpRequest, +) -> std::result::Result { + let query_string = req.query_string(); + let mut indexer_ids = Vec::new(); + let mut date_from: Option = None; + let mut date_to: Option = None; + + for (key, value) in url::form_urlencoded::parse(query_string.as_bytes()) { + match key.as_ref() { + "indexer_ids" => { + if let Ok(id) = value.parse::() { + indexer_ids.push(id); + } + } + "date_from" => { + if let Ok(dt) = NaiveDateTime::parse_from_str(&value, "%Y-%m-%dT%H:%M:%S%.f") { + date_from = Some(dt); + } else if let Ok(dt) = NaiveDateTime::parse_from_str(&value, "%Y-%m-%dT%H:%M:%S") { + date_from = Some(dt); + } + } + "date_to" => { + if let Ok(dt) = NaiveDateTime::parse_from_str(&value, "%Y-%m-%dT%H:%M:%S%.f") { + date_to = Some(dt); + } else if let Ok(dt) = NaiveDateTime::parse_from_str(&value, "%Y-%m-%dT%H:%M:%S") { + date_to = Some(dt); + } + } + _ => {} + } + } + + Ok(GetUserStatsQuery { + indexer_ids, + date_from: date_from + .ok_or_else(|| actix_web::error::ErrorBadRequest("missing date_from"))?, + date_to: date_to.ok_or_else(|| actix_web::error::ErrorBadRequest("missing date_to"))?, + }) +} + #[utoipa::path( get, operation_id = "Get user stats", tag = "User stats", path = "/api/user-stats", - params(GetUserStatsQuery), + params(GetUserStatsQueryParams), responses( - (status = 200, description = "Successfully got user stats", body=UserProfileVec), + (status = 200, description = "Successfully got user stats", body=HashMap), ) )] -pub async fn exec(arc: Data, query: Query) -> Result { +pub async fn exec( + arc: Data, + req: HttpRequest, +) -> std::result::Result { + let query = parse_query_string(&req)?; + + if query.indexer_ids.is_empty() { + return Err(actix_web::error::ErrorBadRequest("indexer_ids is required")); + } + let user_stats = arc .pool - .find_user_stats(query.indexer_id, &query.date_from, &query.date_to) - .await?; - // let test: UserProfileVec = user_stats.into(); - let user_stats_reduced = UserProfileVec::from_vec(user_stats); + .find_user_stats(&query.indexer_ids, &query.date_from, &query.date_to) + .await + .map_err(|e| { + actix_web::error::ErrorInternalServerError(format!("Database error: {}", e)) + })?; + + let mut grouped_profiles: HashMap> = + HashMap::new(); + for profile in user_stats { + grouped_profiles + .entry(profile.indexer_id) + .or_insert_with(Vec::new) + .push(profile); + } + + let grouped_stats: HashMap = grouped_profiles + .into_iter() + .map(|(indexer_id, profiles)| (indexer_id, UserProfileVec::from_vec(profiles))) + .collect(); - Ok(HttpResponse::Ok().json(user_stats_reduced)) + Ok(HttpResponse::Ok().json(grouped_stats)) } diff --git a/backend/src/repositories/user_stats.rs b/backend/src/repositories/user_stats.rs index e901c91..b5344af 100644 --- a/backend/src/repositories/user_stats.rs +++ b/backend/src/repositories/user_stats.rs @@ -78,7 +78,7 @@ impl ConnectionPool { pub async fn find_user_stats( &self, - indexer_id: i64, + indexer_ids: &[i64], date_from: &NaiveDateTime, date_to: &NaiveDateTime, ) -> Result> { @@ -88,11 +88,11 @@ impl ConnectionPool { let indexers: Vec = sqlx::query_as( r#" SELECT * FROM user_profiles - WHERE indexer_id = $1 AND scraped_at BETWEEN $2 AND $3 - ORDER BY scraped_at ASC + WHERE indexer_id = ANY($1) AND scraped_at BETWEEN $2 AND $3 + ORDER BY indexer_id, scraped_at ASC "#, ) - .bind(indexer_id) + .bind(indexer_ids) .bind(date_from) .bind(date_to) .fetch_all(self.borrow()) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1eed2b7..6590c36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -71,6 +71,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1764,6 +1765,7 @@ "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1814,6 +1816,7 @@ "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -2396,6 +2399,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2564,6 +2568,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -2660,6 +2665,7 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -2778,6 +2784,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -3026,6 +3033,7 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3087,6 +3095,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -3134,6 +3143,7 @@ "integrity": "sha512-A0u9snqjCfYaPnqqOaH6MBLVWDUIN4trXn8J3x67uDcXvR7X6Ut8p16N+nYhMCQ9Y7edg2BIRGzfyZsY0IdqoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", @@ -3912,6 +3922,7 @@ "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -4573,6 +4584,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4997,6 +5009,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5059,6 +5072,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5195,6 +5209,7 @@ "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5393,6 +5408,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5412,6 +5428,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.21", "@vue/compiler-sfc": "3.5.21", diff --git a/frontend/src/components/user-stats/IndexerStatsEvolutions.vue b/frontend/src/components/user-stats/IndexerStatsEvolutions.vue index ca5de0f..a592bf8 100644 --- a/frontend/src/components/user-stats/IndexerStatsEvolutions.vue +++ b/frontend/src/components/user-stats/IndexerStatsEvolutions.vue @@ -9,16 +9,18 @@ diff --git a/frontend/src/components/user-stats/IndexerStatsIncreases.vue b/frontend/src/components/user-stats/IndexerStatsIncreases.vue index d95dc83..b02bb14 100644 --- a/frontend/src/components/user-stats/IndexerStatsIncreases.vue +++ b/frontend/src/components/user-stats/IndexerStatsIncreases.vue @@ -10,18 +10,28 @@ From 2396f942c1fff774504e457baf380842c7ff12e7 Mon Sep 17 00:00:00 2001 From: Doonut Date: Wed, 24 Dec 2025 21:08:19 -0800 Subject: [PATCH 2/4] Fix npm linting errors --- .../user-stats/IndexerStatsIncreases.vue | 2 +- .../src/components/user-stats/SearchForm.vue | 35 ++++++------- frontend/src/utils/trackerColors.ts | 51 +++++++++---------- 3 files changed, 42 insertions(+), 46 deletions(-) diff --git a/frontend/src/components/user-stats/IndexerStatsIncreases.vue b/frontend/src/components/user-stats/IndexerStatsIncreases.vue index b02bb14..6ae88f6 100644 --- a/frontend/src/components/user-stats/IndexerStatsIncreases.vue +++ b/frontend/src/components/user-stats/IndexerStatsIncreases.vue @@ -21,7 +21,7 @@ const props = defineProps<{ const postProcessStat = (value: keyof UserProfileScrapedVec) => { let totalIncrease = 0 - + Object.values(props.userStats).forEach((userStatsVec) => { const profileArray = userStatsVec.profile[value] if (profileArray && profileArray.length > 0) { diff --git a/frontend/src/components/user-stats/SearchForm.vue b/frontend/src/components/user-stats/SearchForm.vue index 4beada6..614b009 100644 --- a/frontend/src/components/user-stats/SearchForm.vue +++ b/frontend/src/components/user-stats/SearchForm.vue @@ -28,11 +28,7 @@