diff --git a/repology-webapp/src/endpoints.rs b/repology-webapp/src/endpoints.rs index 0279b19..5fbc6c1 100644 --- a/repology-webapp/src/endpoints.rs +++ b/repology-webapp/src/endpoints.rs @@ -107,6 +107,23 @@ pub enum Endpoint { #[strum(props(path = "/graph/total/problems.svg"))] GraphTotalProblems, + #[strum(props(path = "/graph/repo/:repository_name/problems.svg"))] + GraphRepoProblems, + #[strum(props(path = "/graph/repo/:repository_name/maintainers.svg"))] + GraphRepoMaintainers, + #[strum(props(path = "/graph/repo/:repository_name/projects_total.svg"))] + GraphRepoProjectsTotal, + #[strum(props(path = "/graph/repo/:repository_name/projects_unique.svg"))] + GraphRepoProjectsUnique, + #[strum(props(path = "/graph/repo/:repository_name/projects_newest.svg"))] + GraphRepoProjectsNewest, + #[strum(props(path = "/graph/repo/:repository_name/projects_outdated.svg"))] + GraphRepoProjectsOutdated, + #[strum(props(path = "/graph/repo/:repository_name/projects_problematic.svg"))] + GraphRepoProjectsProblematic, + #[strum(props(path = "/graph/repo/:repository_name/projects_vulnerable.svg"))] + GraphRepoProjectsVulnerable, + // Opensearch #[strum(props(path = "/opensearch/project.xml"))] OpensearchProject, @@ -238,40 +255,26 @@ pub enum Endpoint { // Graphs #[strum(props(path = "/graph/project/:project_name/releases.svg"))] GraphReleases, + #[strum(props(path = "/graph/map_repo_size_fresh.svg"))] GraphMapRepoSizeFresh, #[strum(props(path = "/graph/map_repo_size_fresh_nonunique.svg"))] GraphMapRepoSizeFreshNonunique, #[strum(props(path = "/graph/map_repo_size_freshness.svg"))] GraphMapRepoSizeFreshness, - #[strum(props(path = "/graph/repo/:repository_name/projects_total.svg"))] - GraphRepoProjectsTotal, - #[strum(props(path = "/graph/repo/:repository_name/projects_newest.svg"))] - GraphRepoProjectsNewest, + #[strum(props(path = "/graph/repo/:repository_name/projects_newest_percent.svg"))] GraphRepoProjectsNewestPercent, - #[strum(props(path = "/graph/repo/:repository_name/projects_outdated.svg"))] - GraphRepoProjectsOutdated, #[strum(props(path = "/graph/repo/:repository_name/projects_outdated_percent.svg"))] GraphRepoProjectsOutdatedPercent, - #[strum(props(path = "/graph/repo/:repository_name/projects_unique.svg"))] - GraphRepoProjectsUnique, #[strum(props(path = "/graph/repo/:repository_name/projects_unique_percent.svg"))] GraphRepoProjectsUniquePercent, - #[strum(props(path = "/graph/repo/:repository_name/projects_problematic.svg"))] - GraphRepoProjectsProblematic, #[strum(props(path = "/graph/repo/:repository_name/projects_problematic_percent.svg"))] GraphRepoProjectsProblematicPercent, - #[strum(props(path = "/graph/repo/:repository_name/projects_vulnerable.svg"))] - GraphRepoProjectsVulnerable, #[strum(props(path = "/graph/repo/:repository_name/projects_vulnerable_percent.svg"))] GraphRepoProjectsVulnerablePercent, - #[strum(props(path = "/graph/repo/:repository_name/problems.svg"))] - GraphRepoProblems, #[strum(props(path = "/graph/repo/:repository_name/problems_per_metapackage.svg"))] GraphRepoProblemsPerMetapackage, - #[strum(props(path = "/graph/repo/:repository_name/maintainers.svg"))] - GraphRepoMaintainers, #[strum(props(path = "/graph/repo/:repository_name/packages_per_maintainer.svg"))] GraphRepoPackagesPerMaintainer, diff --git a/repology-webapp/src/lib.rs b/repology-webapp/src/lib.rs index ee6ef6d..bb855a7 100644 --- a/repology-webapp/src/lib.rs +++ b/repology-webapp/src/lib.rs @@ -115,6 +115,14 @@ pub async fn create_app(pool: PgPool) -> Result { .route(GraphTotalProjects.path(), get(views::graph_total_projects)) .route(GraphTotalMaintainers.path(), get(views::graph_total_maintainers)) .route(GraphTotalProblems.path(), get(views::graph_total_problems)) + .route(GraphRepoProblems.path(), get(views::graph_repository_problems)) + .route(GraphRepoMaintainers.path(), get(views::graph_repository_maintainers)) + .route(GraphRepoProjectsTotal.path(), get(views::graph_repository_projects_total)) + .route(GraphRepoProjectsUnique.path(), get(views::graph_repository_projects_unique)) + .route(GraphRepoProjectsNewest.path(), get(views::graph_repository_projects_newest)) + .route(GraphRepoProjectsOutdated.path(), get(views::graph_repository_projects_outdated)) + .route(GraphRepoProjectsProblematic.path(), get(views::graph_repository_projects_problematic)) + .route(GraphRepoProjectsVulnerable.path(), get(views::graph_repository_projects_vulnerable)) .route(Log.path(), get(views::log)) .route(MaintainerRepoFeed.path(), get(views::maintainer_repo_feed)) .route(MaintainerRepoFeedAtom.path(), get(views::maintainer_repo_feed_atom)) diff --git a/repology-webapp/src/views/graph.rs b/repology-webapp/src/views/graph.rs index 2febe93..02b2581 100644 --- a/repology-webapp/src/views/graph.rs +++ b/repology-webapp/src/views/graph.rs @@ -3,11 +3,12 @@ use std::time::Duration; -use axum::extract::State; -use axum::http::{header, HeaderValue}; +use axum::extract::{Path, Query, State}; +use axum::http::{header, HeaderValue, StatusCode}; use axum::response::IntoResponse; use chrono::{DateTime, Utc}; use indoc::indoc; +use serde::Deserialize; use sqlx::PgPool; use crate::graphs::{render_graph, GraphType}; @@ -16,7 +17,231 @@ use crate::state::AppState; const GRAPH_PERIOD: Duration = Duration::from_days(21); -async fn graph_total_generic(pool: &PgPool, field: &str, stroke: &str) -> EndpointResult { +#[derive(Deserialize, Debug)] +pub struct QueryParams { + #[serde(default)] + #[serde(deserialize_with = "crate::query::deserialize_bool_flag")] + pub experimental_history: bool, +} + +async fn graph_repository_generic( + state: &AppState, + repository_name: &str, + experimental_history: bool, + field_name: &str, + stroke: &str, +) -> EndpointResult { + if state + .repository_data_cache + .get_active(&repository_name) + .await + .is_none() + { + return Ok((StatusCode::NOT_FOUND, "repository not found".to_owned()).into_response()); + }; + + let query = if experimental_history { + &format!( + indoc! {r#" + ( + SELECT + ts AS timestamp, + {0}::real AS value + FROM repositories_history_new + WHERE repository_id = (SELECT id FROM repositories WHERE name = $1) AND ts < now() - $3 AND {0} IS NOT NULL + ORDER BY ts DESC + LIMIT 1 + ) + UNION ALL + ( + SELECT + ts AS timestamp, + {0}::real AS value + FROM repositories_history_new + WHERE repository_id = (SELECT id FROM repositories WHERE name = $1) AND ts >= now() - $3 AND {0} IS NOT NULL + ORDER BY ts + ) + "#}, + field_name + ) + } else { + indoc! {r#" + ( + SELECT + ts AS timestamp, + (snapshot->$1->>$2)::real AS value + FROM repositories_history + WHERE ts < now() - $3 AND snapshot->$1->>$2 IS NOT NULL + ORDER BY ts DESC + LIMIT 1 + ) + UNION ALL + ( + SELECT + ts AS timestamp, + (snapshot->$1->>$2)::real AS value + FROM repositories_history + WHERE ts >= now() - $3 AND snapshot->$1->>$2 IS NOT NULL + ORDER BY ts + ) + "#} + }; + let points: Vec<(DateTime, f32)> = sqlx::query_as(query) + .bind(&repository_name) + .bind(&field_name.replace("num_projects", "num_metapackages")) + .bind(&GRAPH_PERIOD) + .fetch_all(&state.pool) + .await?; + + let now = Utc::now(); + + Ok(( + [( + header::CONTENT_TYPE, + HeaderValue::from_static(mime::IMAGE_SVG.as_ref()), + )], + render_graph( + &points + .into_iter() + .map(|(timestamp, value)| ((now - timestamp).to_std().unwrap(), value)) + .collect(), + GraphType::Integer, + GRAPH_PERIOD, + stroke, + ), + ) + .into_response()) +} + +#[cfg_attr(not(feature = "coverage"), tracing::instrument(skip(state)))] +pub async fn graph_repository_maintainers( + Path(repository_name): Path, + Query(query): Query, + State(state): State, +) -> EndpointResult { + graph_repository_generic( + &state, + &repository_name, + query.experimental_history, + "num_maintainers", + "#c000c0", + ) + .await +} + +#[cfg_attr(not(feature = "coverage"), tracing::instrument(skip(state)))] +pub async fn graph_repository_problems( + Path(repository_name): Path, + Query(query): Query, + State(state): State, +) -> EndpointResult { + graph_repository_generic( + &state, + &repository_name, + query.experimental_history, + "num_problems", + "#c00000", + ) + .await +} + +#[cfg_attr(not(feature = "coverage"), tracing::instrument(skip(state)))] +pub async fn graph_repository_projects_total( + Path(repository_name): Path, + Query(query): Query, + State(state): State, +) -> EndpointResult { + graph_repository_generic( + &state, + &repository_name, + query.experimental_history, + "num_projects", + "#000", + ) + .await +} + +#[cfg_attr(not(feature = "coverage"), tracing::instrument(skip(state)))] +pub async fn graph_repository_projects_unique( + Path(repository_name): Path, + Query(query): Query, + State(state): State, +) -> EndpointResult { + graph_repository_generic( + &state, + &repository_name, + query.experimental_history, + "num_projects_unique", + "#5bc0de", + ) + .await +} + +#[cfg_attr(not(feature = "coverage"), tracing::instrument(skip(state)))] +pub async fn graph_repository_projects_newest( + Path(repository_name): Path, + Query(query): Query, + State(state): State, +) -> EndpointResult { + graph_repository_generic( + &state, + &repository_name, + query.experimental_history, + "num_projects_newest", + "#5cb85c", + ) + .await +} + +#[cfg_attr(not(feature = "coverage"), tracing::instrument(skip(state)))] +pub async fn graph_repository_projects_outdated( + Path(repository_name): Path, + Query(query): Query, + State(state): State, +) -> EndpointResult { + graph_repository_generic( + &state, + &repository_name, + query.experimental_history, + "num_projects_outdated", + "#d9534f", + ) + .await +} + +#[cfg_attr(not(feature = "coverage"), tracing::instrument(skip(state)))] +pub async fn graph_repository_projects_problematic( + Path(repository_name): Path, + Query(query): Query, + State(state): State, +) -> EndpointResult { + graph_repository_generic( + &state, + &repository_name, + query.experimental_history, + "num_projects_problematic", + "#808080", + ) + .await +} + +#[cfg_attr(not(feature = "coverage"), tracing::instrument(skip(state)))] +pub async fn graph_repository_projects_vulnerable( + Path(repository_name): Path, + Query(query): Query, + State(state): State, +) -> EndpointResult { + graph_repository_generic( + &state, + &repository_name, + query.experimental_history, + "num_projects_vulnerable", + "#ff0000", + ) + .await +} + +async fn graph_total_generic(pool: &PgPool, field_name: &str, stroke: &str) -> EndpointResult { let points: Vec<(DateTime, f32)> = sqlx::query_as(indoc! {r#" ( SELECT @@ -37,7 +262,7 @@ async fn graph_total_generic(pool: &PgPool, field: &str, stroke: &str) -> Endpoi ORDER BY ts ) "#}) - .bind(&field) + .bind(&field_name) .bind(&GRAPH_PERIOD) .fetch_all(pool) .await?; diff --git a/repology-webapp/tests/fixtures/graphs_data.sql b/repology-webapp/tests/fixtures/graphs_data.sql index c041d85..a5ba671 100644 --- a/repology-webapp/tests/fixtures/graphs_data.sql +++ b/repology-webapp/tests/fixtures/graphs_data.sql @@ -1,4 +1,12 @@ +-- first point is intentionally long in the past to check whether it's handled properly producing a line INSERT INTO statistics_history(ts, snapshot) VALUES - -- point is intentionally long in the past to check whether it's handled properly producing a line - (now() - interval '128 day', '{"num_packages": 0, "num_problems": 0, "num_maintainers": 0, "num_metapackages": 0}'), - (now(), '{"num_packages": 10, "num_problems": 10, "num_maintainers": 10, "num_metapackages": 10}'); + (now() - interval '128 day', '{"num_packages":0,"num_problems":0,"num_maintainers":0,"num_metapackages":0}'), + (now(), '{"num_packages":10,"num_problems":10,"num_maintainers":10,"num_metapackages":10}'); + +INSERT INTO repositories_history(ts, snapshot) VALUES + (now() - interval '128 day', '{"freebsd":{"num_maintainers":0,"num_problems":0,"num_metapackages":0,"num_metapackages_unique":0,"num_metapackages_newest":0,"num_metapackages_outdated":0,"num_metapackages_problematic":0,"num_metapackages_vulnerable":0}}'::jsonb), + (now(), '{"freebsd":{"num_maintainers":10,"num_problems":10,"num_metapackages":10,"num_metapackages_unique":10,"num_metapackages_newest":10,"num_metapackages_outdated":10,"num_metapackages_problematic":10,"num_metapackages_vulnerable":10}}'::jsonb); + +INSERT INTO repositories_history_new(repository_id, ts, num_maintainers, num_problems, num_projects, num_projects_unique, num_projects_newest, num_projects_outdated, num_projects_problematic, num_projects_vulnerable) VALUES + (1, now() - interval '128 day', 0, 0, 0, 0, 0, 0, 0, 0), + (1, now(), 0, 0, 0, 0, 0, 0, 0, 0); diff --git a/repology-webapp/tests/graphs.rs b/repology-webapp/tests/graphs.rs index e1d4957..78d3de1 100644 --- a/repology-webapp/tests/graphs.rs +++ b/repology-webapp/tests/graphs.rs @@ -9,7 +9,7 @@ use sqlx::PgPool; use repology_webapp_test_utils::check_response; #[sqlx::test(migrator = "repology_common::MIGRATOR", fixtures("graphs_data.sql"))] -async fn test_graphs(pool: PgPool) { +async fn test_graphs_total(pool: PgPool) { check_response!( pool, "/graph/total/packages.svg", @@ -19,21 +19,152 @@ async fn test_graphs(pool: PgPool) { ); check_response!( pool, - "/graph/total/packages.svg", + "/graph/total/projects.svg", status OK, content_type IMAGE_SVG, svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, ); check_response!( pool, - "/graph/total/packages.svg", + "/graph/total/maintainers.svg", status OK, content_type IMAGE_SVG, svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, ); check_response!( pool, - "/graph/total/packages.svg", + "/graph/total/problems.svg", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); +} + +#[sqlx::test( + migrator = "repology_common::MIGRATOR", + fixtures("common_repositories.sql", "graphs_data.sql") +)] +async fn test_graphs_repository(pool: PgPool) { + check_response!( + pool, + "/graph/repo/unknown/problems.svg", + status NOT_FOUND, + ); + check_response!( + pool, + "/graph/repo/ubuntu_10/problems.svg", + status NOT_FOUND, + ); + + check_response!( + pool, + "/graph/repo/freebsd/problems.svg", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/maintainers.svg", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/projects_total.svg", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/projects_unique.svg", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/projects_newest.svg", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/projects_outdated.svg", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/projects_problematic.svg", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/projects_vulnerable.svg", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + + check_response!( + pool, + "/graph/repo/freebsd/problems.svg?experimental_history=1", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/maintainers.svg?experimental_history", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/projects_total.svg?experimental_history", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/projects_unique.svg?experimental_history", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/projects_newest.svg?experimental_history", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/projects_outdated.svg?experimental_history", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/projects_problematic.svg?experimental_history", + status OK, + content_type IMAGE_SVG, + svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64, + ); + check_response!( + pool, + "/graph/repo/freebsd/projects_vulnerable.svg?experimental_history", status OK, content_type IMAGE_SVG, svg_xpath "count(//svg:g[1]/svg:line[1])" 1_f64,