From 89fd600dbedb01ab41f75e52e6f03fcbbf1a0624 Mon Sep 17 00:00:00 2001 From: Denis Cornehl Date: Sun, 11 Jan 2026 14:24:34 +0100 Subject: [PATCH 1/2] show average build durations in build lists, crate details --- ...cb7a54564002de4c5728b254488f9a2470d1d.json | 28 ++++++ ...c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json} | 10 ++- Cargo.lock | 8 ++ ...cb7a54564002de4c5728b254488f9a2470d1d.json | 28 ++++++ ...c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json} | 10 ++- ...cb7a54564002de4c5728b254488f9a2470d1d.json | 28 ++++++ ...c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json} | 10 ++- crates/bin/docs_rs_web/Cargo.toml | 1 + crates/bin/docs_rs_web/src/handlers/builds.rs | 15 ++-- .../docs_rs_web/src/handlers/crate_details.rs | 85 +++++++++++++++++- crates/bin/docs_rs_web/src/page/templates.rs | 45 +++++----- .../docs_rs_web/templates/crate/builds.html | 32 +++++-- .../docs_rs_web/templates/crate/details.html | 38 ++++++-- crates/bin/docs_rs_web/templates/macros.html | 2 +- .../docs_rs_web/templates/style/style.scss | 3 +- .../20260111133536_build-durations.down.sql | 3 + .../20260111133536_build-durations.up.sql | 57 ++++++++++++ crates/lib/docs_rs_types/Cargo.toml | 1 + crates/lib/docs_rs_types/src/convert/mod.rs | 86 +++++++++++++++++++ crates/lib/docs_rs_types/src/duration.rs | 52 +++++++++++ crates/lib/docs_rs_types/src/lib.rs | 3 + 21 files changed, 496 insertions(+), 49 deletions(-) create mode 100644 .sqlx/query-2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d.json rename .sqlx/{query-b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee.json => query-e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json} (66%) create mode 100644 crates/bin/cratesfyi/.sqlx/query-2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d.json rename crates/bin/cratesfyi/.sqlx/{query-b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee.json => query-e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json} (66%) create mode 100644 crates/bin/docs_rs_web/.sqlx/query-2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d.json rename crates/bin/docs_rs_web/.sqlx/{query-b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee.json => query-e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json} (66%) create mode 100644 crates/lib/docs_rs_database/migrations/20260111133536_build-durations.down.sql create mode 100644 crates/lib/docs_rs_database/migrations/20260111133536_build-durations.up.sql create mode 100644 crates/lib/docs_rs_types/src/convert/mod.rs create mode 100644 crates/lib/docs_rs_types/src/duration.rs diff --git a/.sqlx/query-2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d.json b/.sqlx/query-2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d.json new file mode 100644 index 000000000..20591e34a --- /dev/null +++ b/.sqlx/query-2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n dr.avg_duration AS \"avg_build_duration_release?: Duration\",\n dc.avg_duration AS \"avg_build_duration_crate?: Duration\"\n FROM\n build_durations_release AS dr\n INNER JOIN build_durations_crate AS dc on dr.crate_id = dc.crate_id\n WHERE rid = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "avg_build_duration_release?: Duration", + "type_info": "Interval" + }, + { + "ordinal": 1, + "name": "avg_build_duration_crate?: Duration", + "type_info": "Interval" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d" +} diff --git a/.sqlx/query-b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee.json b/.sqlx/query-e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json similarity index 66% rename from .sqlx/query-b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee.json rename to .sqlx/query-e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json index 26969a794..0e487319a 100644 --- a/.sqlx/query-b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee.json +++ b/.sqlx/query-e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n builds.id as \"id: BuildId\",\n builds.rustc_version,\n builds.docsrs_version,\n builds.build_status as \"build_status: BuildStatus\",\n COALESCE(builds.build_finished, builds.build_started) as build_time,\n builds.errors\n FROM builds\n INNER JOIN releases ON releases.id = builds.rid\n INNER JOIN crates ON releases.crate_id = crates.id\n WHERE\n crates.name = $1 AND\n releases.version = $2\n ORDER BY builds.id DESC", + "query": "SELECT\n builds.id as \"id: BuildId\",\n builds.rustc_version,\n builds.docsrs_version,\n builds.build_status as \"build_status: BuildStatus\",\n COALESCE(builds.build_finished, builds.build_started) as build_time,\n durations.duration AS \"build_duration?: Duration\",\n builds.errors\n FROM builds\n INNER JOIN build_durations AS durations ON durations.build_id = builds.id\n INNER JOIN releases ON releases.id = builds.rid\n INNER JOIN crates ON releases.crate_id = crates.id\n WHERE\n crates.name = $1 AND\n releases.version = $2\n ORDER BY builds.id DESC", "describe": { "columns": [ { @@ -41,6 +41,11 @@ }, { "ordinal": 5, + "name": "build_duration?: Duration", + "type_info": "Interval" + }, + { + "ordinal": 6, "name": "errors", "type_info": "Text" } @@ -57,8 +62,9 @@ true, false, null, + true, true ] }, - "hash": "b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee" + "hash": "e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120" } diff --git a/Cargo.lock b/Cargo.lock index 8961816c4..34bbf5699 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2320,6 +2320,7 @@ dependencies = [ "sqlx", "strum", "test-case", + "thiserror 2.0.17", "tokio", ] @@ -2421,6 +2422,7 @@ dependencies = [ "grass", "http 1.4.0", "http-body-util", + "humantime", "indoc", "itertools 0.14.0", "kuchikiki", @@ -4019,6 +4021,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "0.14.32" diff --git a/crates/bin/cratesfyi/.sqlx/query-2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d.json b/crates/bin/cratesfyi/.sqlx/query-2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d.json new file mode 100644 index 000000000..20591e34a --- /dev/null +++ b/crates/bin/cratesfyi/.sqlx/query-2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n dr.avg_duration AS \"avg_build_duration_release?: Duration\",\n dc.avg_duration AS \"avg_build_duration_crate?: Duration\"\n FROM\n build_durations_release AS dr\n INNER JOIN build_durations_crate AS dc on dr.crate_id = dc.crate_id\n WHERE rid = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "avg_build_duration_release?: Duration", + "type_info": "Interval" + }, + { + "ordinal": 1, + "name": "avg_build_duration_crate?: Duration", + "type_info": "Interval" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d" +} diff --git a/crates/bin/cratesfyi/.sqlx/query-b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee.json b/crates/bin/cratesfyi/.sqlx/query-e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json similarity index 66% rename from crates/bin/cratesfyi/.sqlx/query-b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee.json rename to crates/bin/cratesfyi/.sqlx/query-e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json index 26969a794..0e487319a 100644 --- a/crates/bin/cratesfyi/.sqlx/query-b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee.json +++ b/crates/bin/cratesfyi/.sqlx/query-e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n builds.id as \"id: BuildId\",\n builds.rustc_version,\n builds.docsrs_version,\n builds.build_status as \"build_status: BuildStatus\",\n COALESCE(builds.build_finished, builds.build_started) as build_time,\n builds.errors\n FROM builds\n INNER JOIN releases ON releases.id = builds.rid\n INNER JOIN crates ON releases.crate_id = crates.id\n WHERE\n crates.name = $1 AND\n releases.version = $2\n ORDER BY builds.id DESC", + "query": "SELECT\n builds.id as \"id: BuildId\",\n builds.rustc_version,\n builds.docsrs_version,\n builds.build_status as \"build_status: BuildStatus\",\n COALESCE(builds.build_finished, builds.build_started) as build_time,\n durations.duration AS \"build_duration?: Duration\",\n builds.errors\n FROM builds\n INNER JOIN build_durations AS durations ON durations.build_id = builds.id\n INNER JOIN releases ON releases.id = builds.rid\n INNER JOIN crates ON releases.crate_id = crates.id\n WHERE\n crates.name = $1 AND\n releases.version = $2\n ORDER BY builds.id DESC", "describe": { "columns": [ { @@ -41,6 +41,11 @@ }, { "ordinal": 5, + "name": "build_duration?: Duration", + "type_info": "Interval" + }, + { + "ordinal": 6, "name": "errors", "type_info": "Text" } @@ -57,8 +62,9 @@ true, false, null, + true, true ] }, - "hash": "b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee" + "hash": "e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120" } diff --git a/crates/bin/docs_rs_web/.sqlx/query-2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d.json b/crates/bin/docs_rs_web/.sqlx/query-2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d.json new file mode 100644 index 000000000..20591e34a --- /dev/null +++ b/crates/bin/docs_rs_web/.sqlx/query-2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n dr.avg_duration AS \"avg_build_duration_release?: Duration\",\n dc.avg_duration AS \"avg_build_duration_crate?: Duration\"\n FROM\n build_durations_release AS dr\n INNER JOIN build_durations_crate AS dc on dr.crate_id = dc.crate_id\n WHERE rid = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "avg_build_duration_release?: Duration", + "type_info": "Interval" + }, + { + "ordinal": 1, + "name": "avg_build_duration_crate?: Duration", + "type_info": "Interval" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "2d8f2d5c887fef11e3184d48181cb7a54564002de4c5728b254488f9a2470d1d" +} diff --git a/crates/bin/docs_rs_web/.sqlx/query-b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee.json b/crates/bin/docs_rs_web/.sqlx/query-e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json similarity index 66% rename from crates/bin/docs_rs_web/.sqlx/query-b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee.json rename to crates/bin/docs_rs_web/.sqlx/query-e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json index 26969a794..0e487319a 100644 --- a/crates/bin/docs_rs_web/.sqlx/query-b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee.json +++ b/crates/bin/docs_rs_web/.sqlx/query-e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n builds.id as \"id: BuildId\",\n builds.rustc_version,\n builds.docsrs_version,\n builds.build_status as \"build_status: BuildStatus\",\n COALESCE(builds.build_finished, builds.build_started) as build_time,\n builds.errors\n FROM builds\n INNER JOIN releases ON releases.id = builds.rid\n INNER JOIN crates ON releases.crate_id = crates.id\n WHERE\n crates.name = $1 AND\n releases.version = $2\n ORDER BY builds.id DESC", + "query": "SELECT\n builds.id as \"id: BuildId\",\n builds.rustc_version,\n builds.docsrs_version,\n builds.build_status as \"build_status: BuildStatus\",\n COALESCE(builds.build_finished, builds.build_started) as build_time,\n durations.duration AS \"build_duration?: Duration\",\n builds.errors\n FROM builds\n INNER JOIN build_durations AS durations ON durations.build_id = builds.id\n INNER JOIN releases ON releases.id = builds.rid\n INNER JOIN crates ON releases.crate_id = crates.id\n WHERE\n crates.name = $1 AND\n releases.version = $2\n ORDER BY builds.id DESC", "describe": { "columns": [ { @@ -41,6 +41,11 @@ }, { "ordinal": 5, + "name": "build_duration?: Duration", + "type_info": "Interval" + }, + { + "ordinal": 6, "name": "errors", "type_info": "Text" } @@ -57,8 +62,9 @@ true, false, null, + true, true ] }, - "hash": "b7d805d65e97349dde8ac57e176bc92ae7ef49af5b09b0fc532f763947bd1aee" + "hash": "e7979edad0ebdbc61e7abc790ff2c1ee3d5a3831bc42e318f5c29cbf8aa6b120" } diff --git a/crates/bin/docs_rs_web/Cargo.toml b/crates/bin/docs_rs_web/Cargo.toml index e016f9295..b4797eb24 100644 --- a/crates/bin/docs_rs_web/Cargo.toml +++ b/crates/bin/docs_rs_web/Cargo.toml @@ -48,6 +48,7 @@ font-awesome-as-a-crate = { path = "../../lib/font-awesome-as-a-crate" } futures-util = { workspace = true } getrandom = "0.3.1" http = { workspace = true } +humantime = "2.3.0" itertools = { workspace = true } lol_html = "2.0.0" mime = { workspace = true } diff --git a/crates/bin/docs_rs_web/src/handlers/builds.rs b/crates/bin/docs_rs_web/src/handlers/builds.rs index 70d3aba22..31113f75a 100644 --- a/crates/bin/docs_rs_web/src/handlers/builds.rs +++ b/crates/bin/docs_rs_web/src/handlers/builds.rs @@ -21,7 +21,7 @@ use docs_rs_build_limits::Limits; use docs_rs_build_queue::{AsyncBuildQueue, PRIORITY_MANUAL_FROM_CRATES_IO}; use docs_rs_context::Context; use docs_rs_headers::CanonicalUrl; -use docs_rs_types::{BuildId, BuildStatus, KrateName, ReqVersion, Version}; +use docs_rs_types::{BuildId, BuildStatus, Duration, KrateName, ReqVersion, Version}; use http::StatusCode; use std::sync::Arc; @@ -32,6 +32,7 @@ pub(crate) struct Build { docsrs_version: Option, build_status: BuildStatus, build_time: Option>, + build_duration: Option, errors: Option, } @@ -195,8 +196,10 @@ async fn get_builds( builds.docsrs_version, builds.build_status as "build_status: BuildStatus", COALESCE(builds.build_finished, builds.build_started) as build_time, + durations.duration AS "build_duration?: Duration", builds.errors FROM builds + INNER JOIN build_durations AS durations ON durations.build_id = builds.id INNER JOIN releases ON releases.id = builds.rid INNER JOIN crates ON releases.crate_id = crates.id WHERE @@ -530,14 +533,14 @@ mod tests { }; Overrides::save(&mut conn, &FOO, limits).await?; - let page = kuchikiki::parse_html().one( + let page = kuchikiki::parse_html().one(dbg!( env.web_app() .await - .get(&format!("/crate/foo/{V1}/builds")) + .assert_success(&format!("/crate/foo/{V1}/builds")) .await? .text() - .await?, - ); + .await? + )); let header = page.select(".about h4").unwrap().next().unwrap(); assert_eq!(header.text_contents(), "foo's sandbox limits"); @@ -550,7 +553,7 @@ mod tests { let values: Vec<_> = values.iter().map(|v| &**v).collect(); assert!(values.contains(&"6.44 GB")); - assert!(values.contains(&"2 hours")); + assert!(values.contains(&"2h")); assert!(values.contains(&"102.4 kB")); assert!(values.contains(&"blocked")); assert!(values.contains(&"1")); diff --git a/crates/bin/docs_rs_web/src/handlers/crate_details.rs b/crates/bin/docs_rs_web/src/handlers/crate_details.rs index 5e7884661..69e47e229 100644 --- a/crates/bin/docs_rs_web/src/handlers/crate_details.rs +++ b/crates/bin/docs_rs_web/src/handlers/crate_details.rs @@ -23,7 +23,9 @@ use docs_rs_database::crate_details::{Release, latest_release, parse_doc_targets use docs_rs_headers::CanonicalUrl; use docs_rs_registry_api::OwnerKind; use docs_rs_storage::{AsyncStorage, PathNotFoundError}; -use docs_rs_types::{BuildId, BuildStatus, CrateId, KrateName, ReleaseId, ReqVersion, Version}; +use docs_rs_types::{ + BuildId, BuildStatus, CrateId, Duration, KrateName, ReleaseId, ReqVersion, Version, +}; use futures_util::stream::TryStreamExt; use serde_json::Value; use std::sync::Arc; @@ -342,6 +344,40 @@ impl CrateDetails { } } +#[derive(Debug, Clone, Default)] +struct BuildStatistics { + avg_build_duration_release: Option, + avg_build_duration_crate: Option, +} + +impl BuildStatistics { + fn has_data(&self) -> bool { + self.avg_build_duration_crate.is_some() || self.avg_build_duration_release.is_some() + } + + async fn fetch_for_release( + conn: &mut sqlx::PgConnection, + release_id: ReleaseId, + ) -> Result { + Ok(sqlx::query_as!( + BuildStatistics, + r#" + SELECT + dr.avg_duration AS "avg_build_duration_release?: Duration", + dc.avg_duration AS "avg_build_duration_crate?: Duration" + FROM + build_durations_release AS dr + INNER JOIN build_durations_crate AS dc on dr.crate_id = dc.crate_id + WHERE rid = $1 + "#, + release_id as _, + ) + .fetch_optional(conn) + .await? + .unwrap_or_default()) + } +} + #[derive(Debug, Clone, Template)] #[template(path = "crate/details.html")] struct CrateDetailsPage { @@ -352,6 +388,7 @@ struct CrateDetailsPage { documented_items: Option, total_items: Option, total_items_needing_examples: Option, + build_statistics: BuildStatistics, items_with_examples: Option, homepage_url: Option, documentation_url: Option, @@ -418,6 +455,9 @@ pub(crate) async fn crate_details_handler( Err(e) => warn!(?e, "error fetching readme"), } + let build_statistics = + BuildStatistics::fetch_for_release(&mut conn, details.release_id).await?; + let CrateDetails { version, name, @@ -454,6 +494,7 @@ pub(crate) async fn crate_details_handler( documented_items, total_items, total_items_needing_examples, + build_statistics, items_with_examples, homepage_url, documentation_url, @@ -644,6 +685,7 @@ mod tests { use docs_rs_registry_api::CrateOwner; use docs_rs_test_fakes::{FakeBuild, fake_release_that_failed_before_build}; use docs_rs_types::KrateName; + use docs_rs_types::testing::{FOO, V1}; use http::StatusCode; use kuchikiki::traits::TendrilSink; use pretty_assertions::assert_eq; @@ -2311,4 +2353,45 @@ path = "src/lib.rs" Ok(()) }); } + + #[tokio::test(flavor = "multi_thread")] + async fn test_build_stats_no_data() -> Result<()> { + let env = TestEnvironment::new().await?; + let mut conn = env.async_conn().await?; + + let stats = BuildStatistics::fetch_for_release(&mut conn, ReleaseId(42)).await?; + assert!(!stats.has_data()); + assert!(stats.avg_build_duration_release.is_none()); + assert!(stats.avg_build_duration_crate.is_none()); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_build_stats_with_build() -> Result<()> { + let env = TestEnvironment::new().await?; + + let rid = env + .fake_release() + .await + .name(&FOO) + .version(V1) + .create() + .await?; + + let mut conn = env.async_conn().await?; + + let stats = BuildStatistics::fetch_for_release(&mut conn, rid).await?; + assert!(stats.has_data()); + assert!(stats.avg_build_duration_release.is_some()); + assert!(stats.avg_build_duration_crate.is_some()); + + assert!( + !BuildStatistics::fetch_for_release(&mut conn, ReleaseId(42)) + .await? + .has_data() + ); + + Ok(()) + } } diff --git a/crates/bin/docs_rs_web/src/page/templates.rs b/crates/bin/docs_rs_web/src/page/templates.rs index 760861da9..3545e9632 100644 --- a/crates/bin/docs_rs_web/src/page/templates.rs +++ b/crates/bin/docs_rs_web/src/page/templates.rs @@ -94,6 +94,7 @@ pub mod filters { use chrono::{DateTime, Utc}; use std::borrow::Cow; use std::fmt::Display; + use std::time::Duration; pub fn escape_html_inner(input: &str) -> askama::Result { if !input.chars().any(|c| "&<>\"'/".contains(c)) { @@ -150,27 +151,10 @@ pub mod filters { } #[askama::filter_fn] - pub fn format_secs(mut value: f32, _: &dyn Values) -> askama::Result { - const TIMES: &[&str] = &["seconds", "minutes", "hours"]; - - let mut chosen_time = &TIMES[0]; - - for time in &TIMES[1..] { - if value / 60.0 >= 1.0 { - chosen_time = time; - value /= 60.0; - } else { - break; - } - } - - // TODO: This formatting section can be optimized, two string allocations aren't needed - let mut value = format!("{value:.1}"); - if value.ends_with(".0") { - value.truncate(value.len() - 2); - } - - Ok(format!("{value} {chosen_time}")) + pub fn format_duration(duration: &Duration, _: &dyn Values) -> askama::Result> { + // we only want seconds precision when rendering the durations. + let duration = Duration::from_secs(duration.as_secs()); + Ok(Safe(humantime::format_duration(duration).to_string())) } /// Dedent a string by removing all leading whitespace @@ -299,3 +283,22 @@ fn render( askama::filters::Safe(icon) } + +#[cfg(test)] +mod tests { + use super::*; + use std::{any::Any, collections::HashMap, time::Duration}; + use test_case::test_case; + + #[test_case(Duration::from_secs(1) => "1s"; "simple")] + #[test_case(Duration::from_micros(2123456) => "2s"; "cuts microseconds")] + #[test_case(Duration::from_secs(2123456) => "24days 13h 50m 56s"; "big")] + fn test_format_duration(duration: Duration) -> String { + let values: HashMap<&str, Box> = HashMap::new(); + + filters::format_duration::default() + .execute(&duration, &values) + .unwrap() + .to_string() + } +} diff --git a/crates/bin/docs_rs_web/templates/crate/builds.html b/crates/bin/docs_rs_web/templates/crate/builds.html index 27647c079..087bfe2c2 100644 --- a/crates/bin/docs_rs_web/templates/crate/builds.html +++ b/crates/bin/docs_rs_web/templates/crate/builds.html @@ -30,7 +30,6 @@
    -
  • {%- for build in builds -%}
  • {%- if build.build_status != "in_progress" -%} @@ -45,14 +44,14 @@ {{ crate::icons::IconX.render_solid(false, false, "") }} {%- endif -%} {#- -#} -
    +
    {%- if let Some(rustc_version) = build.rustc_version -%} {{ rustc_version }} {%- else -%} — {%- endif -%}
    {#- -#} -
    +
    {%- if let Some(docsrs_version) = build.docsrs_version -%} {{ docsrs_version }} {%- else -%} @@ -65,7 +64,14 @@ {%- else -%} — {%- endif -%} -
    {#- -#} +
    +
    + {%- if let Some(build_duration) = build.build_duration -%} + took {{ build_duration|format_duration }} + {%- else -%} + — + {%- endif -%} +
    {#- -#} {%- else -%} @@ -74,8 +80,21 @@
    {{- crate::icons::IconGear.render_solid(false, true, "") -}}
    {#- -#} -
    {#- -#} - In the build queue {#- -#} +
    {#- -#} + in progress {#- -#} +
    +
    + — +
    {#- -#} +
    + — +
    {#- -#} +
    + {%- if let Some(build_duration) = build.build_duration -%} + since {{ build_duration|format_duration }} + {%- else -%} + — + {%- endif -%}
    @@ -111,3 +130,4 @@

    {{ metadata.name }}'s sandbox limits

    {%- endblock body -%} + diff --git a/crates/bin/docs_rs_web/templates/crate/details.html b/crates/bin/docs_rs_web/templates/crate/details.html index 555962e38..12bb565f6 100644 --- a/crates/bin/docs_rs_web/templates/crate/details.html +++ b/crates/bin/docs_rs_web/templates/crate/details.html @@ -39,17 +39,41 @@ {%- if let Some(source_size) = source_size -%}
  • Size
  • - Source code size: {{(*source_size)|filesizeformat}} - {{- crate::icons::IconCircleInfo.render_solid(false, false, "") -}} - This is the summed size of all the files inside the crates.io package for this release. + Source code size: {{(*source_size)|filesizeformat}} + {{- crate::icons::IconCircleInfo.render_solid(false, false, "") -}} + This is the summed size of all the files inside the crates.io package for this release. +
  • {%- if let Some(doc_size) = documentation_size -%}
  • - Documentation size: {{(*doc_size)|filesizeformat}} - {{- crate::icons::IconCircleInfo.render_solid(false, false, "") -}} - This is the summed size of all files generated by rustdoc for all configured targets - + Documentation size: {{(*doc_size)|filesizeformat}} + {{- crate::icons::IconCircleInfo.render_solid(false, false, "") -}} + This is the summed size of all files generated by rustdoc for all configured targets + + +
  • + {%- endif -%} + {%- endif -%} + + {%- if build_statistics.has_data() -%} +
  • Ø build duration
  • + {%- if let Some(duration) = build_statistics.avg_build_duration_release -%} +
  • + this release: {{duration|format_duration}} + {{- crate::icons::IconCircleInfo.render_solid(false, false, "") -}} + Average build duration of successful builds. + + +
  • + {%- endif -%} + {%- if let Some(duration) = build_statistics.avg_build_duration_crate -%} +
  • + all releases: {{duration|format_duration}} + {{- crate::icons::IconCircleInfo.render_solid(false, false, "") -}} + Average build duration of successful builds. + +
  • {%- endif -%} {%- endif -%} diff --git a/crates/bin/docs_rs_web/templates/macros.html b/crates/bin/docs_rs_web/templates/macros.html index c6d3d10e3..95c43cc56 100644 --- a/crates/bin/docs_rs_web/templates/macros.html +++ b/crates/bin/docs_rs_web/templates/macros.html @@ -41,7 +41,7 @@ Maximum rustdoc execution time - {{ limits.timeout.as_secs_f32()|format_secs }} + {{ limits.timeout|format_duration }} diff --git a/crates/bin/docs_rs_web/templates/style/style.scss b/crates/bin/docs_rs_web/templates/style/style.scss index dd5ec025f..ac8eb7faf 100644 --- a/crates/bin/docs_rs_web/templates/style/style.scss +++ b/crates/bin/docs_rs_web/templates/style/style.scss @@ -386,7 +386,8 @@ div.recent-releases-container { } } - .date { + .date, + .duration { font-weight: normal; @media #{$media-sm} { diff --git a/crates/lib/docs_rs_database/migrations/20260111133536_build-durations.down.sql b/crates/lib/docs_rs_database/migrations/20260111133536_build-durations.down.sql new file mode 100644 index 000000000..df6533a78 --- /dev/null +++ b/crates/lib/docs_rs_database/migrations/20260111133536_build-durations.down.sql @@ -0,0 +1,3 @@ +DROP VIEW build_durations_crate; +DROP VIEW build_durations_release; +DROP VIEW build_durations; diff --git a/crates/lib/docs_rs_database/migrations/20260111133536_build-durations.up.sql b/crates/lib/docs_rs_database/migrations/20260111133536_build-durations.up.sql new file mode 100644 index 000000000..fd6dbd2ed --- /dev/null +++ b/crates/lib/docs_rs_database/migrations/20260111133536_build-durations.up.sql @@ -0,0 +1,57 @@ +CREATE VIEW build_durations AS + +SELECT + b.id AS build_id, + CASE + -- for old builds, `build_started` is empty. + WHEN b.build_started IS NULL + THEN NULL + ELSE + CASE + -- for in-progress builds we show the duration until now + WHEN b.build_finished IS NULL + THEN (CURRENT_TIMESTAMP - b.build_started) + -- for finished builds we can show the full duration + ELSE (b.build_finished - b.build_started) + END + END AS duration +FROM + builds AS b +; + +CREATE VIEW build_durations_release AS + +SELECT + r.crate_id, + b.rid, + AVG(bd.duration) AS avg_duration + +FROM + releases AS r +INNER JOIN builds AS b ON r.id = b.rid +INNER JOIN build_durations AS bd ON b.id = bd.build_id + +WHERE + b.build_status = 'success' AND + b.build_started IS NOT NULL + +GROUP BY r.crate_id, b.rid +; + +CREATE VIEW build_durations_crate AS + +SELECT + r.crate_id, + AVG(bd.duration) AS avg_duration + +FROM + releases AS r +INNER JOIN builds AS b ON r.id = b.rid +INNER JOIN build_durations AS bd ON b.id = bd.build_id + +WHERE + b.build_status = 'success' AND + b.build_started IS NOT NULL + +GROUP BY r.crate_id +; diff --git a/crates/lib/docs_rs_types/Cargo.toml b/crates/lib/docs_rs_types/Cargo.toml index eb4949b45..d3f7f6ea8 100644 --- a/crates/lib/docs_rs_types/Cargo.toml +++ b/crates/lib/docs_rs_types/Cargo.toml @@ -20,6 +20,7 @@ serde_json = { workspace = true } serde_with = { workspace = true } sqlx = { workspace = true } strum = { workspace = true } +thiserror = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/crates/lib/docs_rs_types/src/convert/mod.rs b/crates/lib/docs_rs_types/src/convert/mod.rs new file mode 100644 index 000000000..9ad5d5996 --- /dev/null +++ b/crates/lib/docs_rs_types/src/convert/mod.rs @@ -0,0 +1,86 @@ +use sqlx::postgres::types::PgInterval; +use std::time::Duration; + +#[derive(Debug, thiserror::Error)] +pub enum IntervalError { + #[error("months not supported")] + MonthsNotSupported, + #[error("negative duration")] + NegativeDuration, + #[error("duration too large")] + DurationTooLarge, +} + +pub(crate) fn interval_to_duration(interval: PgInterval) -> Result { + if interval.months != 0 { + return Err(IntervalError::MonthsNotSupported); + } + + if interval.days < 0 || interval.microseconds < 0 { + return Err(IntervalError::NegativeDuration); + } + + Ok(Duration::from_hours(interval.days as u64 * 24) + + Duration::from_micros(interval.microseconds as u64)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_month_is_invalid() { + let interval = PgInterval { + months: 1, + days: 0, + microseconds: 0, + }; + let result = interval_to_duration(interval); + assert!(matches!(result, Err(IntervalError::MonthsNotSupported))); + } + + #[test] + fn test_negative_day_is_invalid() { + let interval = PgInterval { + months: 0, + days: -1, + microseconds: 0, + }; + let result = interval_to_duration(interval); + assert!(matches!(result, Err(IntervalError::NegativeDuration))); + } + + #[test] + fn test_negative_ms_is_invalid() { + let interval = PgInterval { + months: 0, + days: 0, + microseconds: -1, + }; + let result = interval_to_duration(interval); + assert!(matches!(result, Err(IntervalError::NegativeDuration))); + } + + #[test] + fn test_simple_conversion() { + let interval = PgInterval { + months: 0, + days: 1, + microseconds: 1_000_000, + }; + let result = interval_to_duration(interval).unwrap(); + assert_eq!(result, Duration::from_secs(86401)); + } + + #[test] + fn test_with_microseconds_conversion() { + const MICROS: i64 = 1_123_456; + let interval = PgInterval { + months: 0, + days: 0, + microseconds: MICROS, + }; + let result = interval_to_duration(interval).unwrap(); + assert_eq!(result, Duration::from_micros(MICROS as u64)); + } +} diff --git a/crates/lib/docs_rs_types/src/duration.rs b/crates/lib/docs_rs_types/src/duration.rs new file mode 100644 index 000000000..d19dae15d --- /dev/null +++ b/crates/lib/docs_rs_types/src/duration.rs @@ -0,0 +1,52 @@ +mod duration_impl { + use anyhow::Result; + use derive_more::{Deref, From, Into}; + use sqlx::postgres::types::PgInterval; + use sqlx::{ + Postgres, + error::BoxDynError, + postgres::{PgTypeInfo, PgValueRef}, + prelude::*, + }; + use std::time::Duration as StdDuration; + + /// NewType around std Duration to be able to use it with sqlx. + /// + /// For now only for decoding intervals from the database. + #[derive(Clone, Debug, Deref, Eq, From, Hash, Into, PartialEq)] + pub struct Duration(pub StdDuration); + + impl Duration { + pub const fn from_secs(secs: u64) -> Duration { + Self(StdDuration::from_secs(secs)) + } + } + + impl Type for Duration { + fn type_info() -> PgTypeInfo { + >::type_info() + } + + fn compatible(ty: &PgTypeInfo) -> bool { + >::compatible(ty) + } + } + + impl TryFrom for Duration { + type Error = crate::convert::IntervalError; + + fn try_from(value: PgInterval) -> Result { + Ok(Self(crate::convert::interval_to_duration(value)?)) + } + } + + impl<'r> Decode<'r, Postgres> for Duration { + fn decode(value: PgValueRef<'r>) -> Result { + let interval: PgInterval = Decode::::decode(value)?; + + Ok(interval.try_into()?) + } + } +} + +pub use duration_impl::Duration; diff --git a/crates/lib/docs_rs_types/src/lib.rs b/crates/lib/docs_rs_types/src/lib.rs index 1373cc6fe..acaa4b148 100644 --- a/crates/lib/docs_rs_types/src/lib.rs +++ b/crates/lib/docs_rs_types/src/lib.rs @@ -1,6 +1,8 @@ mod build_status; mod compression_algorithm; +pub(crate) mod convert; pub mod doc_coverage; +mod duration; mod feature; mod ids; mod krate_name; @@ -12,6 +14,7 @@ mod version; pub use build_status::BuildStatus; pub use compression_algorithm::{CompressionAlgorithm, compression_from_file_extension}; pub use doc_coverage::{DocCoverage, RawFileCoverage}; +pub use duration::Duration; pub use feature::Feature; pub use ids::{BuildId, CrateId, ReleaseId}; pub use krate_name::KrateName; From 510a8c85872f94b52a899e934fc6171ca81ca43c Mon Sep 17 00:00:00 2001 From: Denis Cornehl Date: Mon, 12 Jan 2026 19:42:06 +0100 Subject: [PATCH 2/2] replace humantime dependency with small custom implementation --- Cargo.lock | 7 ----- crates/bin/docs_rs_web/Cargo.toml | 2 -- crates/bin/docs_rs_web/src/page/templates.rs | 27 +++++++++++++++++--- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34bbf5699..4fd4d6b0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2422,7 +2422,6 @@ dependencies = [ "grass", "http 1.4.0", "http-body-util", - "humantime", "indoc", "itertools 0.14.0", "kuchikiki", @@ -4021,12 +4020,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - [[package]] name = "hyper" version = "0.14.32" diff --git a/crates/bin/docs_rs_web/Cargo.toml b/crates/bin/docs_rs_web/Cargo.toml index b4797eb24..cc46d4d6f 100644 --- a/crates/bin/docs_rs_web/Cargo.toml +++ b/crates/bin/docs_rs_web/Cargo.toml @@ -48,7 +48,6 @@ font-awesome-as-a-crate = { path = "../../lib/font-awesome-as-a-crate" } futures-util = { workspace = true } getrandom = "0.3.1" http = { workspace = true } -humantime = "2.3.0" itertools = { workspace = true } lol_html = "2.0.0" mime = { workspace = true } @@ -100,4 +99,3 @@ opentelemetry_sdk = { workspace = true } pretty_assertions = { workspace = true } test-case = { workspace = true } walkdir = { workspace = true } - diff --git a/crates/bin/docs_rs_web/src/page/templates.rs b/crates/bin/docs_rs_web/src/page/templates.rs index 3545e9632..7c8288366 100644 --- a/crates/bin/docs_rs_web/src/page/templates.rs +++ b/crates/bin/docs_rs_web/src/page/templates.rs @@ -152,9 +152,25 @@ pub mod filters { #[askama::filter_fn] pub fn format_duration(duration: &Duration, _: &dyn Values) -> askama::Result> { - // we only want seconds precision when rendering the durations. - let duration = Duration::from_secs(duration.as_secs()); - Ok(Safe(humantime::format_duration(duration).to_string())) + let mut secs = duration.as_secs(); + + let hours = secs / 3_600; + secs %= 3_600; + let minutes = secs / 60; + let seconds = secs % 60; + + let mut parts = Vec::new(); + if hours > 0 { + parts.push(format!("{hours}h")); + } + if minutes > 0 { + parts.push(format!("{minutes}m")); + } + if seconds > 0 || parts.is_empty() { + parts.push(format!("{seconds}s")); + } + + Ok(Safe(parts.join(" "))) } /// Dedent a string by removing all leading whitespace @@ -290,9 +306,12 @@ mod tests { use std::{any::Any, collections::HashMap, time::Duration}; use test_case::test_case; + #[test_case(Duration::from_secs(0) => "0s"; "zero")] #[test_case(Duration::from_secs(1) => "1s"; "simple")] #[test_case(Duration::from_micros(2123456) => "2s"; "cuts microseconds")] - #[test_case(Duration::from_secs(2123456) => "24days 13h 50m 56s"; "big")] + #[test_case(Duration::from_secs(3723) => "1h 2m 3s"; "hours minutes seconds")] + #[test_case(Duration::from_secs(120) => "2m"; "just minutes")] + #[test_case(Duration::from_secs(2123456) => "589h 50m 56s"; "big")] fn test_format_duration(duration: Duration) -> String { let values: HashMap<&str, Box> = HashMap::new();