From df179942f01b4cbd942914a1e33d076735a3a0e4 Mon Sep 17 00:00:00 2001 From: Denis Cornehl Date: Mon, 30 Mar 2026 09:29:17 +0200 Subject: [PATCH 1/4] allow setting global alert from the database / CLI --- ...55c044e87f75430006b1c01069d350a3e0838.json | 15 ++ ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ crates/bin/docs_rs_admin/src/main.rs | 63 ++++++- ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ ...55c044e87f75430006b1c01069d350a3e0838.json | 15 ++ ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ crates/bin/docs_rs_web/src/error.rs | 2 +- crates/bin/docs_rs_web/src/handlers/about.rs | 2 +- .../docs_rs_web/src/handlers/build_details.rs | 2 +- crates/bin/docs_rs_web/src/handlers/builds.rs | 2 +- .../docs_rs_web/src/handlers/crate_details.rs | 2 +- .../bin/docs_rs_web/src/handlers/features.rs | 2 +- crates/bin/docs_rs_web/src/handlers/mod.rs | 50 +++++- .../bin/docs_rs_web/src/handlers/releases.rs | 2 +- .../bin/docs_rs_web/src/handlers/rustdoc.rs | 62 ++++++- crates/bin/docs_rs_web/src/handlers/source.rs | 2 +- crates/bin/docs_rs_web/src/lib.rs | 13 -- .../bin/docs_rs_web/src/page/global_alert.rs | 156 ++++++++++++++++++ crates/bin/docs_rs_web/src/page/mod.rs | 10 +- crates/bin/docs_rs_web/src/page/templates.rs | 29 ++++ crates/bin/docs_rs_web/src/page/web_page.rs | 47 ++++-- .../bin/docs_rs_web/src/utils/html_rewrite.rs | 8 +- .../templates/header/global_alert.html | 7 +- ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ .../src/service_config/global_alert.rs | 61 +++++++ .../mod.rs} | 62 +++++++ ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ ...560588c0187a00f62f3525635182c482eaec3.json | 14 ++ 35 files changed, 746 insertions(+), 50 deletions(-) create mode 100644 .sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json create mode 100644 .sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json create mode 100644 crates/bin/cratesfyi/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json create mode 100644 crates/bin/docs_rs_admin/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json create mode 100644 crates/bin/docs_rs_builder/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json create mode 100644 crates/bin/docs_rs_import_release/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json create mode 100644 crates/bin/docs_rs_watcher/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json create mode 100644 crates/bin/docs_rs_web/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json create mode 100644 crates/bin/docs_rs_web/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json create mode 100644 crates/bin/docs_rs_web/src/page/global_alert.rs create mode 100644 crates/lib/docs_rs_build_limits/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json create mode 100644 crates/lib/docs_rs_build_queue/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json create mode 100644 crates/lib/docs_rs_context/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json create mode 100644 crates/lib/docs_rs_database/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json create mode 100644 crates/lib/docs_rs_database/src/service_config/global_alert.rs rename crates/lib/docs_rs_database/src/{service_config.rs => service_config/mod.rs} (62%) create mode 100644 crates/lib/docs_rs_repository_stats/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json create mode 100644 crates/lib/docs_rs_test_fakes/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json diff --git a/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json b/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json new file mode 100644 index 000000000..037c1c25d --- /dev/null +++ b/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE config SET value = $2 WHERE name = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Json" + ] + }, + "nullable": [] + }, + "hash": "029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838" +} diff --git a/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/cratesfyi/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/bin/cratesfyi/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/bin/cratesfyi/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/docs_rs_admin/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/bin/docs_rs_admin/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/bin/docs_rs_admin/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/docs_rs_admin/src/main.rs b/crates/bin/docs_rs_admin/src/main.rs index 54821f537..61412a81e 100644 --- a/crates/bin/docs_rs_admin/src/main.rs +++ b/crates/bin/docs_rs_admin/src/main.rs @@ -14,7 +14,7 @@ use docs_rs_build_queue::priority::{ use docs_rs_context::Context; use docs_rs_database::{ crate_details, - service_config::{ConfigName, set_config}, + service_config::{AlertSeverity, ConfigName, GlobalAlert, remove_config, set_config}, }; use docs_rs_fastly::CdnBehaviour as _; use docs_rs_headers::SurrogateKey; @@ -359,6 +359,12 @@ enum DatabaseSubcommand { version: Option, }, + /// Manage the global alert shown in the site header + GlobalAlert { + #[command(subcommand)] + command: GlobalAlertSubcommand, + }, + /// temporary command to repackage missing crates into archive storage. /// starts at the earliest release and works forwards. Repackage { @@ -404,6 +410,8 @@ impl DatabaseSubcommand { } .context("Failed to run database migrations")?, + Self::GlobalAlert { command } => command.handle_args(ctx).await?, + Self::Repackage { limit } => { let pool = ctx.pool()?; let storage = ctx.storage()?; @@ -504,6 +512,59 @@ impl DatabaseSubcommand { } } +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +enum GlobalAlertSubcommand { + /// Set the global alert shown in the site header + Set { + #[arg(long)] + url: String, + #[arg(long)] + text: String, + #[arg(long, default_value_t)] + severity: AlertSeverity, + }, + + /// Remove the global alert shown in the site header + Remove, +} + +impl GlobalAlertSubcommand { + async fn handle_args(self, ctx: Context) -> Result<()> { + let mut conn = ctx + .pool()? + .get_async() + .await + .context("failed to get a database connection")?; + + match self { + Self::Set { + url, + text, + severity, + } => { + set_config( + &mut conn, + ConfigName::GlobalAlert, + GlobalAlert { + url, + text, + severity, + }, + ) + .await + .context("failed to set global alert in database")?; + } + Self::Remove => { + remove_config(&mut conn, ConfigName::GlobalAlert) + .await + .context("failed to remove global alert from database")?; + } + } + + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] enum LimitsSubcommand { /// Get sandbox limit overrides for a crate diff --git a/crates/bin/docs_rs_builder/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/bin/docs_rs_builder/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/bin/docs_rs_builder/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/docs_rs_import_release/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/bin/docs_rs_import_release/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/bin/docs_rs_import_release/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/docs_rs_watcher/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/bin/docs_rs_watcher/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/bin/docs_rs_watcher/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/docs_rs_web/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json b/crates/bin/docs_rs_web/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json new file mode 100644 index 000000000..037c1c25d --- /dev/null +++ b/crates/bin/docs_rs_web/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE config SET value = $2 WHERE name = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Json" + ] + }, + "nullable": [] + }, + "hash": "029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838" +} diff --git a/crates/bin/docs_rs_web/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/bin/docs_rs_web/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/bin/docs_rs_web/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/docs_rs_web/src/error.rs b/crates/bin/docs_rs_web/src/error.rs index 080bb3e5e..8ecda8591 100644 --- a/crates/bin/docs_rs_web/src/error.rs +++ b/crates/bin/docs_rs_web/src/error.rs @@ -2,7 +2,7 @@ use crate::{ cache::CachePolicy, handlers::releases::Search, impl_axum_webpage, - page::templates::{RenderBrands, RenderSolid}, + page::templates::{AlertSeverityRender, RenderBrands, RenderSolid}, }; use anyhow::{Result, anyhow}; use askama::Template; diff --git a/crates/bin/docs_rs_web/src/handlers/about.rs b/crates/bin/docs_rs_web/src/handlers/about.rs index 10584abb8..2461683a0 100644 --- a/crates/bin/docs_rs_web/src/handlers/about.rs +++ b/crates/bin/docs_rs_web/src/handlers/about.rs @@ -3,7 +3,7 @@ use crate::{ error::{AxumErrorPage, AxumResult}, extractors::{DbConnection, Path}, impl_axum_webpage, - page::templates::{RenderBrands, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderSolid, filters}, }; use askama::Template; use axum::{extract::Extension, http::StatusCode, response::IntoResponse}; diff --git a/crates/bin/docs_rs_web/src/handlers/build_details.rs b/crates/bin/docs_rs_web/src/handlers/build_details.rs index f8992619e..f9072a8c0 100644 --- a/crates/bin/docs_rs_web/src/handlers/build_details.rs +++ b/crates/bin/docs_rs_web/src/handlers/build_details.rs @@ -6,7 +6,7 @@ use crate::{ impl_axum_webpage, match_release::match_version, metadata::MetaData, - page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, }; use anyhow::Context as _; use askama::Template; diff --git a/crates/bin/docs_rs_web/src/handlers/builds.rs b/crates/bin/docs_rs_web/src/handlers/builds.rs index b90ae9989..be986413e 100644 --- a/crates/bin/docs_rs_web/src/handlers/builds.rs +++ b/crates/bin/docs_rs_web/src/handlers/builds.rs @@ -6,7 +6,7 @@ use crate::{ impl_axum_webpage, match_release::match_version, metadata::MetaData, - page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, }; use anyhow::{Result, anyhow}; use askama::Template; 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 6ecfad115..3857718cd 100644 --- a/crates/bin/docs_rs_web/src/handlers/crate_details.rs +++ b/crates/bin/docs_rs_web/src/handlers/crate_details.rs @@ -8,7 +8,7 @@ use crate::{ impl_axum_webpage, match_release::{MatchedRelease, match_version}, metadata::MetaData, - page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, utils::{get_correct_docsrs_style_file, licenses}, }; use anyhow::{Context, Result, anyhow}; diff --git a/crates/bin/docs_rs_web/src/handlers/features.rs b/crates/bin/docs_rs_web/src/handlers/features.rs index 4f7227e07..775182ba1 100644 --- a/crates/bin/docs_rs_web/src/handlers/features.rs +++ b/crates/bin/docs_rs_web/src/handlers/features.rs @@ -8,7 +8,7 @@ use crate::{ impl_axum_webpage, match_release::match_version, metadata::MetaData, - page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, }; use anyhow::anyhow; use askama::Template; diff --git a/crates/bin/docs_rs_web/src/handlers/mod.rs b/crates/bin/docs_rs_web/src/handlers/mod.rs index c1ccd760e..d19a31fe9 100644 --- a/crates/bin/docs_rs_web/src/handlers/mod.rs +++ b/crates/bin/docs_rs_web/src/handlers/mod.rs @@ -15,7 +15,7 @@ pub(crate) mod status; use crate::Config; use crate::metrics::WebMetrics; use crate::middleware::{csp, security}; -use crate::page::{self, TemplateData}; +use crate::page::{self, TemplateData, global_alert::GlobalAlertCache}; use crate::{cache, routes}; use anyhow::{Context as _, Error, Result, anyhow, bail}; use axum::{ @@ -75,6 +75,7 @@ async fn apply_middleware( template_data: Option>, ) -> Result { let has_templates = template_data.is_some(); + let global_alert_cache = Arc::new(GlobalAlertCache::new(context.pool()?.clone()).await); let web_metrics = Arc::new(WebMetrics::new(&context.meter_provider)); @@ -103,6 +104,7 @@ async fn apply_middleware( .layer(Extension(config.clone())) .layer(Extension(context.registry_api()?.clone())) .layer(Extension(context.storage()?.clone())) + .layer(Extension(global_alert_cache)) .layer(option_layer(template_data.map(Extension))) .layer(middleware::from_fn(csp::csp_middleware)) .layer(option_layer(has_templates.then_some(middleware::from_fn( @@ -231,6 +233,7 @@ mod tests { AxumResponseTestExt, AxumRouterTestExt, TestEnvironment, TestEnvironmentExt as _, async_wrapper, }; + use docs_rs_database::service_config::{AlertSeverity, ConfigName, GlobalAlert, set_config}; use docs_rs_types::{DocCoverage, ReleaseId, Version}; use kuchikiki::traits::TendrilSink; use pretty_assertions::assert_eq; @@ -262,6 +265,51 @@ mod tests { }); } + #[tokio::test(flavor = "multi_thread")] + async fn test_global_alert_is_not_rendered_when_unset() -> Result<()> { + let env = TestEnvironment::new().await?; + + let web = env.web_app().await; + let page = kuchikiki::parse_html().one(web.assert_success("/").await?.text().await?); + + assert_eq!(page.select("a.pure-menu-link.error").unwrap().count(), 0); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_global_alert_is_rendered_when_configured() -> Result<()> { + let env = TestEnvironment::new().await?; + + let mut conn = env.async_conn().await?; + // NOTE: the global alert is cached inside the web-app, so we have to + // set it before we fetch the web-app from the test-environments. + set_config( + &mut conn, + ConfigName::GlobalAlert, + GlobalAlert { + url: "https://example.com/maintenance".into(), + text: "Scheduled maintenance".into(), + severity: AlertSeverity::Warn, + }, + ) + .await?; + + let web = env.web_app().await; + let page = kuchikiki::parse_html().one(web.assert_success("/").await?.text().await?); + let alert = page + .select("a.pure-menu-link.warn") + .unwrap() + .next() + .expect("missing global alert"); + + assert_eq!( + alert.attributes.borrow().get("href"), + Some("https://example.com/maintenance") + ); + assert!(alert.text_contents().contains("Scheduled maintenance")); + Ok(()) + } + #[test] fn test_doc_coverage_for_crate_pages() { async_wrapper(|env| async move { diff --git a/crates/bin/docs_rs_web/src/handlers/releases.rs b/crates/bin/docs_rs_web/src/handlers/releases.rs index 2ea92f704..0a4240e63 100644 --- a/crates/bin/docs_rs_web/src/handlers/releases.rs +++ b/crates/bin/docs_rs_web/src/handlers/releases.rs @@ -9,7 +9,7 @@ use crate::{ impl_axum_webpage, match_release::match_version, metrics::WebMetrics, - page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, }; use anyhow::{Context as _, Result, anyhow}; use askama::Template; diff --git a/crates/bin/docs_rs_web/src/handlers/rustdoc.rs b/crates/bin/docs_rs_web/src/handlers/rustdoc.rs index 22d9cf93e..ec8cc30c6 100644 --- a/crates/bin/docs_rs_web/src/handlers/rustdoc.rs +++ b/crates/bin/docs_rs_web/src/handlers/rustdoc.rs @@ -16,10 +16,10 @@ use crate::{ middleware::csp::Csp, page::{ TemplateData, - templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + global_alert::GlobalAlertCache, + templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, }, - utils, - utils::licenses, + utils::{self, licenses}, }; use anyhow::{Context as _, anyhow}; use askama::Template; @@ -34,7 +34,7 @@ use axum_extra::{ typed_header::TypedHeader, }; use docs_rs_cargo_metadata::Dependency; -use docs_rs_database::Pool; +use docs_rs_database::{Pool, service_config::GlobalAlert}; use docs_rs_headers::{ETagComputer, IfNoneMatch, X_ROBOTS_TAG}; use docs_rs_registry_api::OwnerKind; use docs_rs_rustdoc_json::RustdocJsonFormatVersion; @@ -492,6 +492,7 @@ impl RustdocPage { rustdoc_html: StreamingBlob, max_parse_memory: usize, if_none_match: Option<&IfNoneMatch>, + global_alert: Option, ) -> AxumResponse { let crate_name = &self.metadata.name; @@ -529,6 +530,7 @@ impl RustdocPage { template_data, rustdoc_html.content, max_parse_memory, + global_alert, self.clone(), otel_metrics, )), @@ -555,6 +557,7 @@ pub(crate) async fn rustdoc_html_server_handler( Extension(storage): Extension>, Extension(config): Extension>, Extension(csp): Extension>, + Extension(global_alert_cache): Extension>, RawQuery(original_query): RawQuery, if_none_match: Option>, mut conn: DbConnection, @@ -778,6 +781,7 @@ pub(crate) async fn rustdoc_html_server_handler( blob, config.max_parse_memory, if_none_match.as_deref(), + global_alert_cache.get().await, ) .await) } @@ -1070,6 +1074,7 @@ mod test { use anyhow::{Context, Result}; use chrono::{NaiveDate, Utc}; use docs_rs_cargo_metadata::Dependency; + use docs_rs_database::service_config::{AlertSeverity, ConfigName, GlobalAlert, set_config}; use docs_rs_registry_api::{CrateOwner, OwnerKind}; use docs_rs_rustdoc_json::{ RUSTDOC_JSON_COMPRESSION_ALGORITHMS, read_format_version_from_rustdoc_json, @@ -1135,6 +1140,55 @@ mod test { .with_context(|| anyhow::anyhow!("no redirect found for {}", path)) } + #[tokio::test(flavor = "multi_thread")] + async fn rustdoc_page_renders_global_alert() -> Result<()> { + let env = TestEnvironment::new().await?; + + let mut conn = env.async_conn().await?; + // NOTE: the global alert is cached inside the web-app, so we have to + // set it before we fetch the web-app from the test-environments. + set_config( + &mut conn, + ConfigName::GlobalAlert, + GlobalAlert { + url: "https://example.com/maintenance".into(), + text: "Scheduled maintenance".into(), + severity: AlertSeverity::Warn, + }, + ) + .await?; + drop(conn); + + env.fake_release() + .await + .name("dummy") + .version("0.1.0") + .rustdoc_file("dummy/index.html") + .create() + .await?; + + let web = env.web_app().await; + + let page = kuchikiki::parse_html().one( + web.assert_success("/dummy/0.1.0/dummy/") + .await? + .text() + .await?, + ); + let alert = page + .select("a.pure-menu-link.warn") + .expect("invalid selector") + .next() + .context("missing global alert on rustdoc page")?; + + assert_eq!( + alert.attributes.borrow().get("href"), + Some("https://example.com/maintenance") + ); + assert!(alert.text_contents().contains("Scheduled maintenance")); + Ok(()) + } + #[test_case(true)] #[test_case(false)] // https://github.com/rust-lang/docs.rs/issues/2313 diff --git a/crates/bin/docs_rs_web/src/handlers/source.rs b/crates/bin/docs_rs_web/src/handlers/source.rs index be702cf76..687f7a8ab 100644 --- a/crates/bin/docs_rs_web/src/handlers/source.rs +++ b/crates/bin/docs_rs_web/src/handlers/source.rs @@ -10,7 +10,7 @@ use crate::{ impl_axum_webpage, match_release::match_version, metadata::MetaData, - page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, }; use anyhow::{Context as _, Result}; diff --git a/crates/bin/docs_rs_web/src/lib.rs b/crates/bin/docs_rs_web/src/lib.rs index 4ddbb8463..edd28d7c3 100644 --- a/crates/bin/docs_rs_web/src/lib.rs +++ b/crates/bin/docs_rs_web/src/lib.rs @@ -28,16 +28,3 @@ pub use docs_rs_build_limits::DEFAULT_MAX_TARGETS; pub use docs_rs_utils::{APP_USER_AGENT, BUILD_VERSION, RUSTDOC_STATIC_STORAGE_PREFIX}; pub use font_awesome_as_a_crate::icons; pub use handlers::run_web_server; - -use page::GlobalAlert; - -// Warning message shown in the navigation bar of every page. Set to `None` to hide it. -pub(crate) static GLOBAL_ALERT: Option = None; -/* -pub(crate) static GLOBAL_ALERT: Option = Some(GlobalAlert { - url: "https://blog.rust-lang.org/2019/09/18/upcoming-docsrs-changes.html", - text: "Upcoming docs.rs breaking changes!", - css_class: "error", - fa_icon: "exclamation-triangle", -}); -*/ diff --git a/crates/bin/docs_rs_web/src/page/global_alert.rs b/crates/bin/docs_rs_web/src/page/global_alert.rs new file mode 100644 index 000000000..61d70447c --- /dev/null +++ b/crates/bin/docs_rs_web/src/page/global_alert.rs @@ -0,0 +1,156 @@ +use anyhow::{Context as _, Result}; +use docs_rs_database::{ + Pool, + service_config::{ConfigName, GlobalAlert, get_config}, +}; +use std::{sync::Arc, time::Duration}; +use tokio::{ + sync::RwLock, + task::JoinHandle, + time::{MissedTickBehavior, interval}, +}; +use tracing::{debug, error}; + +#[derive(Debug)] +struct State { + value: Option, +} + +#[derive(Debug)] +pub(crate) struct GlobalAlertCache { + background_task: JoinHandle<()>, + state: Arc>, +} + +impl GlobalAlertCache { + const TTL: Duration = Duration::from_secs(600); // 5 minutes + + pub(crate) async fn new(pool: Pool) -> Self { + Self::new_with_ttl(pool, Self::TTL).await + } + + async fn new_with_ttl(pool: Pool, ttl: Duration) -> Self { + let initial_value = match Self::load_from_pool(&pool).await { + Ok(value) => value, + Err(err) => { + error!(?err, "failed to load initial global alert"); + None + } + }; + + let state = Arc::new(RwLock::new(State { + value: initial_value, + })); + let refresh_state = Arc::clone(&state); + + let background_task = tokio::spawn(async move { + let mut refresh_interval = interval(ttl); + refresh_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + // Consume the immediate tick because we already did the initial load. + refresh_interval.tick().await; + + loop { + refresh_interval.tick().await; + + debug!("loading global alert from database"); + match Self::load_from_pool(&pool).await { + Ok(value) => { + let mut state = refresh_state.write().await; + state.value = value; + } + Err(err) => { + error!(?err, "failed to refresh global alert, keeping cached value"); + } + } + } + }); + + Self { + state, + background_task, + } + } + + async fn load_from_pool(pool: &Pool) -> Result> { + let mut conn = pool + .get_async() + .await + .context("failed to get DB connection for global alert")?; + + get_config::(&mut conn, ConfigName::GlobalAlert) + .await + .context("failed to load global alert from config") + } + + pub(crate) async fn get(&self) -> Option { + self.state.read().await.value.clone() + } +} + +impl Drop for GlobalAlertCache { + fn drop(&mut self) { + self.background_task.abort(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::TestEnvironment; + use anyhow::Result; + use docs_rs_database::service_config::{AlertSeverity, ConfigName, set_config}; + use tokio::time::sleep; + + #[tokio::test(flavor = "multi_thread")] + async fn cache_loads_immediately_and_keeps_previous_value_on_reload_failure() -> Result<()> { + let env = TestEnvironment::new().await?; + + let mut conn = env.async_conn().await?; + set_config( + &mut conn, + ConfigName::GlobalAlert, + GlobalAlert { + url: "https://example.com/maintenance".into(), + text: "Scheduled maintenance".into(), + severity: AlertSeverity::Warn, + }, + ) + .await?; + drop(conn); + + let cache = + GlobalAlertCache::new_with_ttl(env.pool()?.clone(), Duration::from_millis(25)).await; + + assert_eq!( + cache.get().await.as_ref().map(|alert| alert.text.as_str()), + Some("Scheduled maintenance") + ); + + let mut conn = env.async_conn().await?; + sqlx::query!( + "UPDATE config SET value = $2 WHERE name = $1", + "global_alert", + serde_json::json!({ + "url": 1, + "text": false + }), + ) + .execute(&mut *conn) + .await?; + drop(conn); + + sleep(Duration::from_millis(75)).await; + + assert_eq!( + cache.get().await, + Some(GlobalAlert { + url: "https://example.com/maintenance".into(), + text: "Scheduled maintenance".into(), + severity: AlertSeverity::Warn, + }) + ); + + Ok(()) + } +} diff --git a/crates/bin/docs_rs_web/src/page/mod.rs b/crates/bin/docs_rs_web/src/page/mod.rs index 8a0146f98..7d90ddc90 100644 --- a/crates/bin/docs_rs_web/src/page/mod.rs +++ b/crates/bin/docs_rs_web/src/page/mod.rs @@ -1,12 +1,6 @@ +pub(crate) mod global_alert; pub(crate) mod templates; pub(crate) mod web_page; +pub(crate) use docs_rs_database::service_config::GlobalAlert; pub(crate) use templates::TemplateData; - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub(crate) struct GlobalAlert { - pub(crate) url: &'static str, - pub(crate) text: &'static str, - pub(crate) css_class: &'static str, - pub(crate) fa_icon: crate::icons::IconTriangleExclamation, -} diff --git a/crates/bin/docs_rs_web/src/page/templates.rs b/crates/bin/docs_rs_web/src/page/templates.rs index c570920f6..466c71670 100644 --- a/crates/bin/docs_rs_web/src/page/templates.rs +++ b/crates/bin/docs_rs_web/src/page/templates.rs @@ -1,6 +1,7 @@ use crate::handlers::rustdoc::RustdocPage; use anyhow::{Context as _, Result}; use askama::Template; +use docs_rs_database::service_config::AlertSeverity; use std::sync::Arc; use tracing::trace; @@ -269,6 +270,34 @@ impl RenderBrands for T { } } +/// how to render the Severity for a GlobalAlert +pub(crate) trait AlertSeverityRender { + fn css_class(&self) -> &'static str; + fn render_icon_solid(&self, fw: bool, spin: bool, extra: &str) + -> askama::filters::Safe; +} + +impl AlertSeverityRender for AlertSeverity { + fn css_class(&self) -> &'static str { + match self { + Self::Warn => "warn", + Self::Error => "error", + } + } + + fn render_icon_solid( + &self, + fw: bool, + spin: bool, + extra: &str, + ) -> askama::filters::Safe { + match self { + Self::Warn => crate::icons::IconTriangleExclamation.render_solid(fw, spin, extra), + Self::Error => crate::icons::IconCircleXmark.render_solid(fw, spin, extra), + } + } +} + fn render( icon_kind: &str, css_class: &str, diff --git a/crates/bin/docs_rs_web/src/page/web_page.rs b/crates/bin/docs_rs_web/src/page/web_page.rs index 3946af94e..dc374b89e 100644 --- a/crates/bin/docs_rs_web/src/page/web_page.rs +++ b/crates/bin/docs_rs_web/src/page/web_page.rs @@ -1,4 +1,8 @@ -use crate::{error::AxumNope, middleware::csp::Csp, page::templates::TemplateData}; +use crate::{ + error::AxumNope, + middleware::csp::Csp, + page::{GlobalAlert, global_alert::GlobalAlertCache, templates::TemplateData}, +}; use axum::{ body::Body, extract::Request as AxumRequest, @@ -9,8 +13,12 @@ use futures_util::future::{BoxFuture, FutureExt}; use http::header::CONTENT_LENGTH; use std::sync::Arc; -pub(crate) trait AddCspNonce: IntoResponse { - fn render_with_csp_nonce(&mut self, csp_nonce: String) -> askama::Result; +pub(crate) trait AddTemplateValues: IntoResponse { + fn render_with_template_values( + &mut self, + csp_nonce: String, + global_alert: Option, + ) -> askama::Result; } #[macro_export] @@ -24,9 +32,18 @@ macro_rules! impl_axum_webpage { $(, cpu_intensive_rendering = $cpu_intensive_rendering:expr)? $(,)? ) => { - impl $crate::page::web_page::AddCspNonce for $page { - fn render_with_csp_nonce(&mut self, csp_nonce: String) -> askama::Result { - let values: (&str, &dyn std::any::Any) = ("csp_nonce", &csp_nonce); + impl $crate::page::web_page::AddTemplateValues for $page { + fn render_with_template_values( + &mut self, + csp_nonce: String, + global_alert: Option<$crate::page::GlobalAlert>, + ) -> askama::Result { + let values: std::collections::HashMap<&str, &dyn std::any::Any> = [ + ("csp_nonce", &csp_nonce as &dyn std::any::Any), + ("global_alert", &global_alert as &dyn std::any::Any), + ] + .into_iter() + .collect(); self.render_with_values(&values) } } @@ -95,7 +112,7 @@ macro_rules! impl_axum_webpage { /// the context. #[derive(Clone)] pub(crate) struct DelayedTemplateRender { - pub template: Arc>, + pub template: Arc>, pub cpu_intensive_rendering: bool, } @@ -103,6 +120,7 @@ fn render_response( mut response: AxumResponse, templates: Arc, csp_nonce: String, + global_alert: Option, ) -> BoxFuture<'static, AxumResponse> { async move { if let Some(render) = response.extensions_mut().remove::() { @@ -112,18 +130,19 @@ fn render_response( } = render; let mut template = Arc::into_inner(template).unwrap(); let csp_nonce_clone = csp_nonce.clone(); + let global_alert_clone = global_alert.clone(); let result: Result = if cpu_intensive_rendering { templates .render_in_threadpool(move || { template - .render_with_csp_nonce(csp_nonce_clone) + .render_with_template_values(csp_nonce_clone, global_alert_clone) .map_err(|err| err.into()) }) .await } else { template - .render_with_csp_nonce(csp_nonce_clone) + .render_with_template_values(csp_nonce_clone, global_alert_clone) .map_err(|err| err.into()) }; @@ -138,6 +157,7 @@ fn render_response( AxumNope::InternalError(err).into_response(), templates, csp_nonce, + global_alert, ) .await; } @@ -170,7 +190,14 @@ pub(crate) async fn render_templates_middleware(req: AxumRequest, next: Next) -> .nonce() .to_owned(); + let global_alert = req + .extensions() + .get::>() + .expect("global alert cache request extension not found") + .get() + .await; + let response = next.run(req).await; - render_response(response, templates, csp_nonce).await + render_response(response, templates, csp_nonce, global_alert).await } diff --git a/crates/bin/docs_rs_web/src/utils/html_rewrite.rs b/crates/bin/docs_rs_web/src/utils/html_rewrite.rs index 307f10a58..3aea7001b 100644 --- a/crates/bin/docs_rs_web/src/utils/html_rewrite.rs +++ b/crates/bin/docs_rs_web/src/utils/html_rewrite.rs @@ -10,9 +10,10 @@ use anyhow::{Context as _, anyhow}; use askama::Template; use async_stream::stream; use axum::body::Bytes; +use docs_rs_database::service_config::GlobalAlert; use futures_util::{Stream, StreamExt as _}; use lol_html::{element, errors::RewritingError}; -use std::sync::Arc; +use std::{any::Any, collections::HashMap, sync::Arc}; use tokio::{io::AsyncRead, task::JoinHandle}; use tokio_util::io::ReaderStream; use tracing::{Span, error, instrument}; @@ -39,6 +40,7 @@ pub(crate) fn rewrite_rustdoc_html_stream( template_data: Arc, mut reader: R, max_allowed_memory_usage: usize, + global_alert: Option, data: Arc, otel_metrics: Arc, ) -> impl Stream> + Send + 'static @@ -69,7 +71,9 @@ where let head_html = Head::new(&data).render().unwrap(); let vendored_html = Vendored.render().unwrap(); let body_html = Body.render().unwrap(); - let topbar_html = data.render().unwrap(); + let values: HashMap<&str, &dyn Any> = + HashMap::from_iter([("global_alert", &global_alert as &dyn Any)]); + let topbar_html = data.render_with_values(&values).unwrap(); // Before: ... rustdoc content ... // After: diff --git a/crates/bin/docs_rs_web/templates/header/global_alert.html b/crates/bin/docs_rs_web/templates/header/global_alert.html index fcca2535e..f9fa8c01d 100644 --- a/crates/bin/docs_rs_web/templates/header/global_alert.html +++ b/crates/bin/docs_rs_web/templates/header/global_alert.html @@ -1,10 +1,11 @@ {# Get the current global alert #} +{%- let global_alert = askama::get_value::>("global_alert").unwrap() -%} {# If there is a global alert, render it #} -{%- if let Some(global_alert) = crate::GLOBAL_ALERT -%} +{%- if let Some(global_alert) = global_alert -%}
  • - - {{- global_alert.fa_icon.render_solid(false, false, "") }} + + {{- global_alert.severity.render_icon_solid(false, false, "") }} {{ global_alert.text -}}
  • diff --git a/crates/lib/docs_rs_build_limits/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/lib/docs_rs_build_limits/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/lib/docs_rs_build_limits/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/lib/docs_rs_build_queue/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/lib/docs_rs_build_queue/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/lib/docs_rs_build_queue/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/lib/docs_rs_context/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/lib/docs_rs_context/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/lib/docs_rs_context/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/lib/docs_rs_database/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/lib/docs_rs_database/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/lib/docs_rs_database/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/lib/docs_rs_database/src/service_config/global_alert.rs b/crates/lib/docs_rs_database/src/service_config/global_alert.rs new file mode 100644 index 000000000..e5d46931b --- /dev/null +++ b/crates/lib/docs_rs_database/src/service_config/global_alert.rs @@ -0,0 +1,61 @@ +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; +use std::{fmt, str::FromStr}; +use strum::VariantArray; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, VariantArray)] +pub enum AlertSeverity { + #[default] + Warn, + Error, +} + +impl fmt::Display for AlertSeverity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Warn => "warn", + Self::Error => "error", + } + ) + } +} + +impl FromStr for AlertSeverity { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + if s.eq_ignore_ascii_case("warn") { + Ok(Self::Warn) + } else if s.eq_ignore_ascii_case("error") { + Ok(Self::Error) + } else { + bail!("invalid severity: {s}") + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GlobalAlert { + pub url: String, + pub text: String, + #[serde(default)] + pub severity: AlertSeverity, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_is_from_str_for_all_variants() { + for severity in AlertSeverity::VARIANTS { + assert_eq!( + *severity, + severity.to_string().parse::().unwrap() + ); + } + } +} diff --git a/crates/lib/docs_rs_database/src/service_config.rs b/crates/lib/docs_rs_database/src/service_config/mod.rs similarity index 62% rename from crates/lib/docs_rs_database/src/service_config.rs rename to crates/lib/docs_rs_database/src/service_config/mod.rs index 93ac86c2e..c8931e8e4 100644 --- a/crates/lib/docs_rs_database/src/service_config.rs +++ b/crates/lib/docs_rs_database/src/service_config/mod.rs @@ -1,6 +1,10 @@ +mod global_alert; + use anyhow::Result; use serde::{Serialize, de::DeserializeOwned}; +pub use global_alert::{AlertSeverity, GlobalAlert}; + #[derive(strum::IntoStaticStr)] #[strum(serialize_all = "snake_case")] pub enum ConfigName { @@ -8,6 +12,7 @@ pub enum ConfigName { LastSeenIndexReference, QueueLocked, Toolchain, + GlobalAlert, } pub async fn set_config( @@ -28,6 +33,14 @@ pub async fn set_config( Ok(()) } +pub async fn remove_config(conn: &mut sqlx::PgConnection, name: ConfigName) -> anyhow::Result<()> { + let name: &'static str = name.into(); + sqlx::query!("DELETE FROM config WHERE name = $1;", name) + .execute(conn) + .await?; + Ok(()) +} + pub async fn get_config(conn: &mut sqlx::PgConnection, name: ConfigName) -> Result> where T: DeserializeOwned, @@ -56,6 +69,7 @@ mod tests { #[test_case(ConfigName::RustcVersion, "rustc_version")] #[test_case(ConfigName::QueueLocked, "queue_locked")] #[test_case(ConfigName::LastSeenIndexReference, "last_seen_index_reference")] + #[test_case(ConfigName::GlobalAlert, "global_alert")] fn test_configname_variants(variant: ConfigName, expected: &'static str) { let name: &'static str = variant.into(); assert_eq!(name, expected); @@ -107,4 +121,52 @@ mod tests { ); Ok(()) } + + #[tokio::test(flavor = "multi_thread")] + async fn test_remove_existing_config() -> anyhow::Result<()> { + let test_metrics = TestMetrics::new(); + let db = TestDatabase::new(&Config::test_config()?, test_metrics.provider()).await?; + + let mut conn = db.async_conn().await?; + set_config( + &mut conn, + ConfigName::RustcVersion, + Value::String("some value".into()), + ) + .await?; + + assert_eq!( + get_config(&mut conn, ConfigName::RustcVersion).await?, + Some("some value".to_string()) + ); + + remove_config(&mut conn, ConfigName::RustcVersion).await?; + + assert!( + get_config::(&mut conn, ConfigName::RustcVersion) + .await? + .is_none() + ); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_remove_missing_config_is_noop() -> anyhow::Result<()> { + let test_metrics = TestMetrics::new(); + let db = TestDatabase::new(&Config::test_config()?, test_metrics.provider()).await?; + + let mut conn = db.async_conn().await?; + sqlx::query!("DELETE FROM config") + .execute(&mut *conn) + .await?; + + remove_config(&mut conn, ConfigName::RustcVersion).await?; + + assert!( + get_config::(&mut conn, ConfigName::RustcVersion) + .await? + .is_none() + ); + Ok(()) + } } diff --git a/crates/lib/docs_rs_repository_stats/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/lib/docs_rs_repository_stats/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/lib/docs_rs_repository_stats/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/lib/docs_rs_test_fakes/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/lib/docs_rs_test_fakes/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/lib/docs_rs_test_fakes/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} From d893eae259cfca0655aa19c9364b1862f2d62314 Mon Sep 17 00:00:00 2001 From: Denis Cornehl Date: Tue, 7 Apr 2026 20:11:19 +0200 Subject: [PATCH 2/4] extend global-alert with queue length alert & prep for more --- crates/bin/docs_rs_web/src/handlers/mod.rs | 101 +++++++++++++++++- .../bin/docs_rs_web/src/handlers/releases.rs | 30 ++++++ .../bin/docs_rs_web/src/handlers/rustdoc.rs | 8 +- .../bin/docs_rs_web/src/page/global_alert.rs | 78 +++++++++----- crates/bin/docs_rs_web/src/page/mod.rs | 2 +- crates/bin/docs_rs_web/src/page/web_page.rs | 22 ++-- .../bin/docs_rs_web/src/utils/html_rewrite.rs | 7 +- .../templates/header/global_alert.html | 33 ++++-- .../templates/releases/build_queue.html | 9 ++ crates/lib/docs_rs_build_queue/src/config.rs | 6 ++ .../src/queue/non_blocking.rs | 86 ++++++++++++++- .../src/testing/test_env/non_blocking.rs | 16 ++- 12 files changed, 336 insertions(+), 62 deletions(-) diff --git a/crates/bin/docs_rs_web/src/handlers/mod.rs b/crates/bin/docs_rs_web/src/handlers/mod.rs index d19a31fe9..d391b059b 100644 --- a/crates/bin/docs_rs_web/src/handlers/mod.rs +++ b/crates/bin/docs_rs_web/src/handlers/mod.rs @@ -75,7 +75,9 @@ async fn apply_middleware( template_data: Option>, ) -> Result { let has_templates = template_data.is_some(); - let global_alert_cache = Arc::new(GlobalAlertCache::new(context.pool()?.clone()).await); + let global_alert_cache = Arc::new( + GlobalAlertCache::new(context.pool()?.clone(), context.build_queue()?.clone()).await, + ); let web_metrics = Arc::new(WebMetrics::new(&context.meter_provider)); @@ -233,10 +235,12 @@ mod tests { AxumResponseTestExt, AxumRouterTestExt, TestEnvironment, TestEnvironmentExt as _, async_wrapper, }; + use docs_rs_config::AppConfig as _; use docs_rs_database::service_config::{AlertSeverity, ConfigName, GlobalAlert, set_config}; - use docs_rs_types::{DocCoverage, ReleaseId, Version}; + use docs_rs_types::{DocCoverage, KrateName, ReleaseId, Version, testing::V1}; use kuchikiki::traits::TendrilSink; use pretty_assertions::assert_eq; + use std::str::FromStr; use test_case::test_case; async fn release(version: &str, env: &TestEnvironment) -> ReleaseId { @@ -277,7 +281,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn test_global_alert_is_rendered_when_configured() -> Result<()> { + async fn test_manual_global_alert_renders_configured_link() -> Result<()> { let env = TestEnvironment::new().await?; let mut conn = env.async_conn().await?; @@ -310,6 +314,97 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "multi_thread")] + async fn test_queue_alert_is_rendered_when_threshold_is_exceeded() -> Result<()> { + let mut queue_config = docs_rs_build_queue::Config::test_config()?; + queue_config.public_alert_threshold = 1; + let env = TestEnvironment::builder() + .build_queue_config(queue_config) + .build() + .await?; + let queue = env.build_queue()?.clone(); + + for idx in 0..2 { + let name = KrateName::from_str(&format!("queued-crate-{idx}"))?; + queue.add_crate(&name, &V1, 0, None).await?; + } + + let web = env.web_app().await; + let page = kuchikiki::parse_html().one(web.assert_success("/").await?.text().await?); + let alert = page + .select("a.pure-menu-link.warn") + .unwrap() + .next() + .expect("missing queue alert"); + + assert_eq!( + alert.attributes.borrow().get("href"), + Some("/releases/queue") + ); + assert!(alert.text_contents().contains("long build queue")); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_manual_alert_wins_when_multiple_alerts_are_active() -> Result<()> { + let mut queue_config = docs_rs_build_queue::Config::test_config()?; + queue_config.public_alert_threshold = 1; + let env = TestEnvironment::builder() + .build_queue_config(queue_config) + .build() + .await?; + + let mut conn = env.async_conn().await?; + set_config( + &mut conn, + ConfigName::GlobalAlert, + GlobalAlert { + url: "https://example.com/maintenance".into(), + text: "Scheduled maintenance".into(), + severity: AlertSeverity::Error, + }, + ) + .await?; + drop(conn); + + let queue = env.build_queue()?.clone(); + for idx in 0..2 { + let name = KrateName::from_str(&format!("queued-crate-{idx}"))?; + queue.add_crate(&name, &V1, 0, None).await?; + } + + let web = env.web_app().await; + let page = kuchikiki::parse_html().one(web.assert_success("/").await?.text().await?); + let alert = page + .select("a.pure-menu-link.error") + .unwrap() + .next() + .expect("missing manual alert"); + + assert_eq!(alert.attributes.borrow().get("href"), Some("#")); + assert!(alert.text_contents().contains("Scheduled maintenance")); + let dropdown_links = page + .select("ul.pure-menu-children a.pure-menu-link") + .unwrap() + .map(|link| { + ( + link.attributes.borrow().get("href").unwrap().to_string(), + link.text_contents(), + ) + }) + .collect::>(); + + assert!(dropdown_links.iter().any(|(href, text)| { + href == "https://example.com/maintenance" && text.contains("Scheduled maintenance") + })); + assert!( + dropdown_links + .iter() + .any(|(href, text)| href == "/releases/queue" && text.contains("long build queue")) + ); + Ok(()) + } + #[test] fn test_doc_coverage_for_crate_pages() { async_wrapper(|env| async move { diff --git a/crates/bin/docs_rs_web/src/handlers/releases.rs b/crates/bin/docs_rs_web/src/handlers/releases.rs index 0a4240e63..05a527130 100644 --- a/crates/bin/docs_rs_web/src/handlers/releases.rs +++ b/crates/bin/docs_rs_web/src/handlers/releases.rs @@ -733,6 +733,7 @@ struct BuildQueuePage { rebuild_queue: Vec, in_progress_builds: Vec, expand_rebuild_queue: bool, + show_length_warning: bool, } impl_axum_webpage! { BuildQueuePage } @@ -798,6 +799,8 @@ pub(crate) async fn build_queue_handler( }) .collect::>(); + let show_length_warning = build_queue.build_queue_is_too_long(queue.iter()); + queue.retain_mut(|krate| { if krate.priority >= PRIORITY_CONTINUOUS { rebuild_queue.push(krate.clone()); @@ -817,6 +820,7 @@ pub(crate) async fn build_queue_handler( rebuild_queue, in_progress_builds, expand_rebuild_queue: params.expand.is_some(), + show_length_warning, }) } @@ -843,6 +847,7 @@ mod tests { use reqwest::StatusCode; use serde_json::json; use std::collections::HashSet; + use std::str::FromStr; use test_case::test_case; #[test] @@ -1819,6 +1824,7 @@ mod tests { .expect("missing heading") .any(|el| el.text_contents().contains("active CDN deployments")) ); + assert_eq!(empty.select(".warning").unwrap().count(), 0); let queue = env.build_queue()?; queue.add_crate(&FOO, &V1, 0, None).await?; @@ -1854,6 +1860,30 @@ mod tests { }); } + #[tokio::test(flavor = "multi_thread")] + async fn test_releases_queue_shows_length_warning_when_threshold_is_exceeded() -> Result<()> { + let env = TestEnvironment::new().await?; + let web = env.web_app().await; + let queue = env.build_queue()?; + + for idx in 0..1001 { + let name = KrateName::from_str(&format!("queued-crate-{idx}"))?; + queue.add_crate(&name, &V1, 0, None).await?; + } + + let page = kuchikiki::parse_html().one(web.get("/releases/queue").await?.text().await?); + let warning = page + .select(".warning") + .expect("missing warning container") + .next() + .expect("missing queue warning"); + + assert!(warning.text_contents().contains("build queue is too long")); + assert!(warning.text_contents().contains("The team is notified")); + + Ok(()) + } + #[test] fn test_releases_queue_in_progress() { async_wrapper(|env| async move { diff --git a/crates/bin/docs_rs_web/src/handlers/rustdoc.rs b/crates/bin/docs_rs_web/src/handlers/rustdoc.rs index ec8cc30c6..771ec5fc5 100644 --- a/crates/bin/docs_rs_web/src/handlers/rustdoc.rs +++ b/crates/bin/docs_rs_web/src/handlers/rustdoc.rs @@ -15,7 +15,7 @@ use crate::{ metrics::WebMetrics, middleware::csp::Csp, page::{ - TemplateData, + ActiveAlerts, TemplateData, global_alert::GlobalAlertCache, templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, }, @@ -34,7 +34,7 @@ use axum_extra::{ typed_header::TypedHeader, }; use docs_rs_cargo_metadata::Dependency; -use docs_rs_database::{Pool, service_config::GlobalAlert}; +use docs_rs_database::Pool; use docs_rs_headers::{ETagComputer, IfNoneMatch, X_ROBOTS_TAG}; use docs_rs_registry_api::OwnerKind; use docs_rs_rustdoc_json::RustdocJsonFormatVersion; @@ -492,7 +492,7 @@ impl RustdocPage { rustdoc_html: StreamingBlob, max_parse_memory: usize, if_none_match: Option<&IfNoneMatch>, - global_alert: Option, + alerts: ActiveAlerts, ) -> AxumResponse { let crate_name = &self.metadata.name; @@ -530,7 +530,7 @@ impl RustdocPage { template_data, rustdoc_html.content, max_parse_memory, - global_alert, + alerts, self.clone(), otel_metrics, )), diff --git a/crates/bin/docs_rs_web/src/page/global_alert.rs b/crates/bin/docs_rs_web/src/page/global_alert.rs index 61d70447c..e15f5ea17 100644 --- a/crates/bin/docs_rs_web/src/page/global_alert.rs +++ b/crates/bin/docs_rs_web/src/page/global_alert.rs @@ -1,4 +1,5 @@ use anyhow::{Context as _, Result}; +use docs_rs_build_queue::AsyncBuildQueue; use docs_rs_database::{ Pool, service_config::{ConfigName, GlobalAlert, get_config}, @@ -11,9 +12,11 @@ use tokio::{ }; use tracing::{debug, error}; +pub(crate) type ActiveAlerts = Vec; + #[derive(Debug)] struct State { - value: Option, + snapshot: ActiveAlerts, } #[derive(Debug)] @@ -23,23 +26,23 @@ pub(crate) struct GlobalAlertCache { } impl GlobalAlertCache { - const TTL: Duration = Duration::from_secs(600); // 5 minutes + const TTL: Duration = Duration::from_secs(300); // 5 minutes - pub(crate) async fn new(pool: Pool) -> Self { - Self::new_with_ttl(pool, Self::TTL).await + pub(crate) async fn new(pool: Pool, build_queue: Arc) -> Self { + Self::new_with_ttl(pool, build_queue, Self::TTL).await } - async fn new_with_ttl(pool: Pool, ttl: Duration) -> Self { - let initial_value = match Self::load_from_pool(&pool).await { - Ok(value) => value, + async fn new_with_ttl(pool: Pool, build_queue: Arc, ttl: Duration) -> Self { + let initial_snapshot = match Self::load_from_sources(&pool, &build_queue).await { + Ok(snapshot) => snapshot, Err(err) => { - error!(?err, "failed to load initial global alert"); - None + error!(?err, "failed to load initial alerts snapshot"); + ActiveAlerts::default() } }; let state = Arc::new(RwLock::new(State { - value: initial_value, + snapshot: initial_snapshot, })); let refresh_state = Arc::clone(&state); @@ -53,14 +56,17 @@ impl GlobalAlertCache { loop { refresh_interval.tick().await; - debug!("loading global alert from database"); - match Self::load_from_pool(&pool).await { - Ok(value) => { + debug!("loading alerts snapshot"); + match Self::load_from_sources(&pool, &build_queue).await { + Ok(snapshot) => { let mut state = refresh_state.write().await; - state.value = value; + state.snapshot = snapshot; } Err(err) => { - error!(?err, "failed to refresh global alert, keeping cached value"); + error!( + ?err, + "failed to refresh alerts snapshot, keeping cached value" + ); } } } @@ -72,19 +78,33 @@ impl GlobalAlertCache { } } - async fn load_from_pool(pool: &Pool) -> Result> { + async fn load_from_sources(pool: &Pool, build_queue: &AsyncBuildQueue) -> Result { let mut conn = pool .get_async() .await - .context("failed to get DB connection for global alert")?; + .context("failed to get DB connection for alerts")?; + + let mut active_alerts = ActiveAlerts::new(); - get_config::(&mut conn, ConfigName::GlobalAlert) + if let Some(alert) = get_config::(&mut conn, ConfigName::GlobalAlert) .await - .context("failed to load global alert from config") + .context("failed to load manual global alert from config")? + { + active_alerts.push(alert); + } + + active_alerts.extend( + build_queue + .gather_alerts() + .await + .context("failed to load build queue alerts")?, + ); + + Ok(active_alerts) } - pub(crate) async fn get(&self) -> Option { - self.state.read().await.value.clone() + pub(crate) async fn get(&self) -> ActiveAlerts { + self.state.read().await.snapshot.clone() } } @@ -99,7 +119,7 @@ mod tests { use super::*; use crate::testing::TestEnvironment; use anyhow::Result; - use docs_rs_database::service_config::{AlertSeverity, ConfigName, set_config}; + use docs_rs_database::service_config::{AlertSeverity, set_config}; use tokio::time::sleep; #[tokio::test(flavor = "multi_thread")] @@ -119,11 +139,15 @@ mod tests { .await?; drop(conn); - let cache = - GlobalAlertCache::new_with_ttl(env.pool()?.clone(), Duration::from_millis(25)).await; + let cache = GlobalAlertCache::new_with_ttl( + env.pool()?.clone(), + env.build_queue()?.clone(), + Duration::from_millis(25), + ) + .await; assert_eq!( - cache.get().await.as_ref().map(|alert| alert.text.as_str()), + cache.get().await.first().map(|alert| alert.text.as_str()), Some("Scheduled maintenance") ); @@ -143,8 +167,8 @@ mod tests { sleep(Duration::from_millis(75)).await; assert_eq!( - cache.get().await, - Some(GlobalAlert { + cache.get().await.first(), + Some(&GlobalAlert { url: "https://example.com/maintenance".into(), text: "Scheduled maintenance".into(), severity: AlertSeverity::Warn, diff --git a/crates/bin/docs_rs_web/src/page/mod.rs b/crates/bin/docs_rs_web/src/page/mod.rs index 7d90ddc90..f19799653 100644 --- a/crates/bin/docs_rs_web/src/page/mod.rs +++ b/crates/bin/docs_rs_web/src/page/mod.rs @@ -2,5 +2,5 @@ pub(crate) mod global_alert; pub(crate) mod templates; pub(crate) mod web_page; -pub(crate) use docs_rs_database::service_config::GlobalAlert; +pub(crate) use global_alert::ActiveAlerts; pub(crate) use templates::TemplateData; diff --git a/crates/bin/docs_rs_web/src/page/web_page.rs b/crates/bin/docs_rs_web/src/page/web_page.rs index dc374b89e..be442e0e7 100644 --- a/crates/bin/docs_rs_web/src/page/web_page.rs +++ b/crates/bin/docs_rs_web/src/page/web_page.rs @@ -1,7 +1,7 @@ use crate::{ error::AxumNope, middleware::csp::Csp, - page::{GlobalAlert, global_alert::GlobalAlertCache, templates::TemplateData}, + page::{ActiveAlerts, global_alert::GlobalAlertCache, templates::TemplateData}, }; use axum::{ body::Body, @@ -17,7 +17,7 @@ pub(crate) trait AddTemplateValues: IntoResponse { fn render_with_template_values( &mut self, csp_nonce: String, - global_alert: Option, + alerts: ActiveAlerts, ) -> askama::Result; } @@ -36,11 +36,11 @@ macro_rules! impl_axum_webpage { fn render_with_template_values( &mut self, csp_nonce: String, - global_alert: Option<$crate::page::GlobalAlert>, + alerts: $crate::page::ActiveAlerts, ) -> askama::Result { let values: std::collections::HashMap<&str, &dyn std::any::Any> = [ ("csp_nonce", &csp_nonce as &dyn std::any::Any), - ("global_alert", &global_alert as &dyn std::any::Any), + ("alerts", &alerts as &dyn std::any::Any), ] .into_iter() .collect(); @@ -120,7 +120,7 @@ fn render_response( mut response: AxumResponse, templates: Arc, csp_nonce: String, - global_alert: Option, + alerts: ActiveAlerts, ) -> BoxFuture<'static, AxumResponse> { async move { if let Some(render) = response.extensions_mut().remove::() { @@ -130,19 +130,19 @@ fn render_response( } = render; let mut template = Arc::into_inner(template).unwrap(); let csp_nonce_clone = csp_nonce.clone(); - let global_alert_clone = global_alert.clone(); + let alerts_clone = alerts.clone(); let result: Result = if cpu_intensive_rendering { templates .render_in_threadpool(move || { template - .render_with_template_values(csp_nonce_clone, global_alert_clone) + .render_with_template_values(csp_nonce_clone, alerts_clone) .map_err(|err| err.into()) }) .await } else { template - .render_with_template_values(csp_nonce_clone, global_alert_clone) + .render_with_template_values(csp_nonce_clone, alerts_clone) .map_err(|err| err.into()) }; @@ -157,7 +157,7 @@ fn render_response( AxumNope::InternalError(err).into_response(), templates, csp_nonce, - global_alert, + alerts, ) .await; } @@ -190,7 +190,7 @@ pub(crate) async fn render_templates_middleware(req: AxumRequest, next: Next) -> .nonce() .to_owned(); - let global_alert = req + let alerts = req .extensions() .get::>() .expect("global alert cache request extension not found") @@ -199,5 +199,5 @@ pub(crate) async fn render_templates_middleware(req: AxumRequest, next: Next) -> let response = next.run(req).await; - render_response(response, templates, csp_nonce, global_alert).await + render_response(response, templates, csp_nonce, alerts).await } diff --git a/crates/bin/docs_rs_web/src/utils/html_rewrite.rs b/crates/bin/docs_rs_web/src/utils/html_rewrite.rs index 3aea7001b..cbd526fea 100644 --- a/crates/bin/docs_rs_web/src/utils/html_rewrite.rs +++ b/crates/bin/docs_rs_web/src/utils/html_rewrite.rs @@ -2,7 +2,7 @@ use crate::{ handlers::rustdoc::RustdocPage, metrics::WebMetrics, page::{ - TemplateData, + ActiveAlerts, TemplateData, templates::{Body, Head, Vendored}, }, }; @@ -10,7 +10,6 @@ use anyhow::{Context as _, anyhow}; use askama::Template; use async_stream::stream; use axum::body::Bytes; -use docs_rs_database::service_config::GlobalAlert; use futures_util::{Stream, StreamExt as _}; use lol_html::{element, errors::RewritingError}; use std::{any::Any, collections::HashMap, sync::Arc}; @@ -40,7 +39,7 @@ pub(crate) fn rewrite_rustdoc_html_stream( template_data: Arc, mut reader: R, max_allowed_memory_usage: usize, - global_alert: Option, + alerts: ActiveAlerts, data: Arc, otel_metrics: Arc, ) -> impl Stream> + Send + 'static @@ -72,7 +71,7 @@ where let vendored_html = Vendored.render().unwrap(); let body_html = Body.render().unwrap(); let values: HashMap<&str, &dyn Any> = - HashMap::from_iter([("global_alert", &global_alert as &dyn Any)]); + HashMap::from_iter([("alerts", &alerts as &dyn Any)]); let topbar_html = data.render_with_values(&values).unwrap(); // Before: ... rustdoc content ... diff --git a/crates/bin/docs_rs_web/templates/header/global_alert.html b/crates/bin/docs_rs_web/templates/header/global_alert.html index f9fa8c01d..f848b83ff 100644 --- a/crates/bin/docs_rs_web/templates/header/global_alert.html +++ b/crates/bin/docs_rs_web/templates/header/global_alert.html @@ -1,12 +1,29 @@ -{# Get the current global alert #} -{%- let global_alert = askama::get_value::>("global_alert").unwrap() -%} +{%- let alerts = askama::get_value::("alerts").unwrap() -%} -{# If there is a global alert, render it #} -{%- if let Some(global_alert) = global_alert -%} -
  • - - {{- global_alert.severity.render_icon_solid(false, false, "") }} - {{ global_alert.text -}} +{%- if alerts.len() == 1 -%} +
  • + {% let alert = alerts.first().unwrap() %} + + {{- alert.severity.render_icon_solid(false, false, "") }} + {{ alert.text -}}
  • +{%- elif alerts.len() >= 2 -%} + {% let first = alerts.first().unwrap() %} + {% endif %} diff --git a/crates/bin/docs_rs_web/templates/releases/build_queue.html b/crates/bin/docs_rs_web/templates/releases/build_queue.html index beb11030e..fa44caf25 100644 --- a/crates/bin/docs_rs_web/templates/releases/build_queue.html +++ b/crates/bin/docs_rs_web/templates/releases/build_queue.html @@ -18,6 +18,15 @@ {%- block body -%}
    + + {%- if show_length_warning %} +
    + The docs.rs build queue is too long.
    + Building your crate might take longer, up to a couple of days.
    + The team is notified. +
    + {%- endif %} +
    Currently being built diff --git a/crates/lib/docs_rs_build_queue/src/config.rs b/crates/lib/docs_rs_build_queue/src/config.rs index 6438589aa..878489ded 100644 --- a/crates/lib/docs_rs_build_queue/src/config.rs +++ b/crates/lib/docs_rs_build_queue/src/config.rs @@ -8,6 +8,7 @@ pub struct Config { pub build_attempts: u16, pub deprioritize_workspace_size: u16, pub delay_between_build_attempts: Duration, + pub public_alert_threshold: usize, } impl Default for Config { @@ -16,6 +17,7 @@ impl Default for Config { build_attempts: 5, deprioritize_workspace_size: 20, delay_between_build_attempts: Duration::from_secs(60), + public_alert_threshold: 1000, } } } @@ -36,6 +38,10 @@ impl AppConfig for Config { config.deprioritize_workspace_size = size; } + if let Some(length) = maybe_env::("DOCSRS_QUEUE_PUBLIC_ALERT_THRESHOLD")? { + config.public_alert_threshold = length; + } + Ok(config) } } diff --git a/crates/lib/docs_rs_build_queue/src/queue/non_blocking.rs b/crates/lib/docs_rs_build_queue/src/queue/non_blocking.rs index 245cf5c53..3fd3934f0 100644 --- a/crates/lib/docs_rs_build_queue/src/queue/non_blocking.rs +++ b/crates/lib/docs_rs_build_queue/src/queue/non_blocking.rs @@ -1,8 +1,11 @@ -use crate::{Config, PRIORITY_DEFAULT, PRIORITY_DEPRIORITIZED, QueuedCrate, metrics}; -use anyhow::Result; +use crate::{ + Config, PRIORITY_DEFAULT, PRIORITY_DEPRIORITIZED, PRIORITY_MANUAL_FROM_CRATES_IO, QueuedCrate, + metrics, +}; +use anyhow::{Context as _, Result}; use docs_rs_database::{ Pool, - service_config::{ConfigName, get_config, set_config}, + service_config::{AlertSeverity, ConfigName, GlobalAlert, get_config, set_config}, }; use docs_rs_opentelemetry::AnyMeterProvider; use docs_rs_repository_stats::workspaces; @@ -260,6 +263,39 @@ impl AsyncBuildQueue { Ok(()) } + + pub fn build_queue_is_too_long<'a>( + &self, + queued_crates: impl Iterator, + ) -> bool { + queued_crates + .filter(|qc| qc.priority < PRIORITY_MANUAL_FROM_CRATES_IO) + .count() + > self.config.public_alert_threshold + } + + /// fetch the current queue alerts + pub async fn gather_alerts(&self) -> Result> { + let queue_pending_count = self + .pending_count_by_priority() + .await + .context("failed to fetch queue length for alerts")? + .into_iter() + .filter_map(|(prio, amount)| (prio < PRIORITY_MANUAL_FROM_CRATES_IO).then_some(amount)) + .sum::(); + + let mut alerts = Vec::with_capacity(1); + + if queue_pending_count > self.config.public_alert_threshold { + alerts.push(GlobalAlert { + url: "/releases/queue".into(), + text: "long build queue".into(), + severity: AlertSeverity::Warn, + }); + } + + Ok(alerts) + } } /// Locking functions. @@ -582,4 +618,48 @@ mod tests { Ok(()) } + + #[tokio::test(flavor = "multi_thread")] + async fn test_public_alert_threshold_boundary() -> Result<()> { + let mut config = Config::from_environment()?; + config.public_alert_threshold = 1; + let env = test_queue_with_config(config).await?; + let queue = env.queue; + + queue.add_crate(&FOO, &V1, 0, None).await?; + + assert!(!queue.build_queue_is_too_long(queue.queued_crates().await?.iter())); + assert!(queue.gather_alerts().await?.is_empty()); + + queue.add_crate(&BAR, &V1, 0, None).await?; + + assert!(queue.build_queue_is_too_long(queue.queued_crates().await?.iter())); + assert_eq!( + queue.gather_alerts().await?, + vec![GlobalAlert { + url: "/releases/queue".into(), + text: "long build queue".into(), + severity: AlertSeverity::Warn, + }] + ); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_public_alert_ignores_manual_crates() -> Result<()> { + let mut config = Config::from_environment()?; + config.public_alert_threshold = 0; + let env = test_queue_with_config(config).await?; + let queue = env.queue; + + queue + .add_crate(&FOO, &V1, PRIORITY_MANUAL_FROM_CRATES_IO, None) + .await?; + + assert!(!queue.build_queue_is_too_long(queue.queued_crates().await?.iter())); + assert!(queue.gather_alerts().await?.is_empty()); + + Ok(()) + } } diff --git a/crates/lib/docs_rs_context/src/testing/test_env/non_blocking.rs b/crates/lib/docs_rs_context/src/testing/test_env/non_blocking.rs index 4b96d6724..98583d1f5 100644 --- a/crates/lib/docs_rs_context/src/testing/test_env/non_blocking.rs +++ b/crates/lib/docs_rs_context/src/testing/test_env/non_blocking.rs @@ -1,6 +1,7 @@ use crate::Context; use anyhow::Result; use bon::bon; +use docs_rs_build_queue::AsyncBuildQueue; use docs_rs_config::AppConfig; use docs_rs_database::{AsyncPoolClient, Config as DatabaseConfig, testing::TestDatabase}; use docs_rs_fastly::Cdn; @@ -43,6 +44,7 @@ impl TestEnvironment { config: Option, registry_api_config: Option, storage_config: Option, + build_queue_config: Option, ) -> Result { docs_rs_logging::testing::init(); @@ -75,6 +77,18 @@ impl TestEnvironment { let test_storage = TestStorage::from_config(storage_config.clone(), metrics.provider()).await?; + let build_queue_config = Arc::new(if let Some(config) = build_queue_config { + config + } else { + docs_rs_build_queue::Config::from_environment()? + }); + + let build_queue = Arc::new(AsyncBuildQueue::new( + db.pool().clone(), + build_queue_config.clone(), + metrics.provider(), + )); + Ok(Self { config: app_config, context: Context::builder() @@ -83,7 +97,7 @@ impl TestEnvironment { .meter_provider(metrics.provider().clone()) .pool(db_config.into(), db.pool().clone()) .storage(storage_config.clone(), test_storage.storage()) - .with_build_queue()? + .build_queue(build_queue_config, build_queue) .registry_api(registry_api_config, registry_api.into()) .with_repository_stats()? .maybe_cdn( From a75f7d7c54e9b4bdc7fab61ab0e853d760e5260a Mon Sep 17 00:00:00 2001 From: Guillaume Gomez Date: Wed, 8 Apr 2026 14:52:57 +0200 Subject: [PATCH 3/4] Add status reporting page --- crates/bin/docs_rs_web/src/handlers/about.rs | 36 +++++++++++++++++++ .../templates/core/about/status.html | 23 ++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 crates/bin/docs_rs_web/templates/core/about/status.html diff --git a/crates/bin/docs_rs_web/src/handlers/about.rs b/crates/bin/docs_rs_web/src/handlers/about.rs index 2461683a0..b678ca4cc 100644 --- a/crates/bin/docs_rs_web/src/handlers/about.rs +++ b/crates/bin/docs_rs_web/src/handlers/about.rs @@ -7,6 +7,7 @@ use crate::{ }; use askama::Template; use axum::{extract::Extension, http::StatusCode, response::IntoResponse}; +use chrono::{DateTime, NaiveDate, Utc}; use docs_rs_build_limits::Limits; use docs_rs_context::Context; use docs_rs_database::service_config::{ConfigName, get_config}; @@ -31,6 +32,27 @@ impl_axum_webpage!( cache_policy = |_| CachePolicy::ShortInCdnAndBrowser, ); +#[derive(Debug, Clone, PartialEq, Eq)] +struct Abnormality { + title: &'static str, + explanation: &'static str, + start_time: Option>, +} + +#[derive(Template)] +#[template(path = "core/about/status.html")] +#[derive(Debug, Clone, PartialEq, Eq)] +struct AboutStatus { + abnormalities: Vec, +} + +impl_axum_webpage!( + AboutStatus, + // NOTE: potential future improvement: serve a special surrogate key, and + // purge that after we updated the local toolchain. + cache_policy = |_| CachePolicy::ShortInCdnAndBrowser, +); + pub(crate) async fn about_builds_handler( mut conn: DbConnection, Extension(context): Extension>, @@ -75,6 +97,20 @@ pub(crate) async fn about_handler(subpage: Option>) -> AxumResult AboutPageRedirection.into_response(), "download" => AboutPageDownload.into_response(), "rustdoc-json" => AboutPageRustdocJson.into_response(), + "status" => AboutStatus { + abnormalities: vec![ + Abnormality { + title: "Build queue is abnormally long", + explanation: "The build queue currently contains more than 100 crates, so it might take a while before new published crates get documented.", + start_time: Some(NaiveDate::from_ymd_opt(2023, 1, 30).unwrap().and_hms_opt(19, 32, 33).unwrap().and_utc()), + }, + Abnormality { + title: "High number of requests", + explanation: "There is a huge amount of HTTP requests currently, pages load might be slower than usual.", + start_time: None, + }, + ], + }.into_response(), _ => { let msg = "This /about page does not exist. \ Perhaps you are interested in creating it?"; diff --git a/crates/bin/docs_rs_web/templates/core/about/status.html b/crates/bin/docs_rs_web/templates/core/about/status.html new file mode 100644 index 000000000..5548ba22a --- /dev/null +++ b/crates/bin/docs_rs_web/templates/core/about/status.html @@ -0,0 +1,23 @@ +{% extends "about-base.html" %} + +{%- block title -%} Docs.rs status {%- endblock title -%} + +{%- block body -%} +

    Docs.rs status

    + +
    +
    + {%- if abnormalities.is_empty() %} +

    No abnormalities detected currently.

    + {%- else %} + {%- for abnormality in abnormalities %} +

    {{abnormality.title}}

    {#- -#} +

    {{abnormality.explanation}}

    + {%- if let Some(start_time) = abnormality.start_time %} +

    This abnormality is ongoing since the {{start_time.format("%Y-%m-%d %H:%M:%S").to_string() }}.

    + {%- endif %} + {%- endfor %} + {%- endif %} +
    +
    +{%- endblock body %} From d933ca1a36997c56ad06eeeca373f8c3327153ff Mon Sep 17 00:00:00 2001 From: Denis Cornehl Date: Wed, 15 Apr 2026 23:33:51 +0200 Subject: [PATCH 4/4] rename to abnormalities, finish up the rest --- Cargo.lock | 3 + crates/bin/docs_rs_admin/Cargo.toml | 1 + crates/bin/docs_rs_admin/src/main.rs | 46 ++- crates/bin/docs_rs_web/src/handlers/about.rs | 139 +++++-- crates/bin/docs_rs_web/src/handlers/mod.rs | 63 ++-- .../bin/docs_rs_web/src/handlers/rustdoc.rs | 38 +- .../bin/docs_rs_web/src/page/global_alert.rs | 180 --------- crates/bin/docs_rs_web/src/page/mod.rs | 4 +- crates/bin/docs_rs_web/src/page/templates.rs | 2 +- crates/bin/docs_rs_web/src/page/warnings.rs | 341 ++++++++++++++++++ crates/bin/docs_rs_web/src/page/web_page.rs | 29 +- crates/bin/docs_rs_web/src/routes.rs | 1 + .../bin/docs_rs_web/src/utils/html_rewrite.rs | 5 +- .../templates/core/about/status.html | 14 +- .../templates/header/abnormalities.html | 27 ++ .../templates/header/global_alert.html | 29 -- .../templates/header/topbar_end.html | 4 +- crates/lib/docs_rs_build_queue/Cargo.toml | 1 + crates/lib/docs_rs_build_queue/src/config.rs | 8 +- .../src/queue/non_blocking.rs | 34 +- crates/lib/docs_rs_database/Cargo.toml | 1 + .../src/service_config/abnormalities.rs | 100 +++++ .../src/service_config/global_alert.rs | 61 ---- .../src/service_config/mod.rs | 8 +- 24 files changed, 742 insertions(+), 397 deletions(-) delete mode 100644 crates/bin/docs_rs_web/src/page/global_alert.rs create mode 100644 crates/bin/docs_rs_web/src/page/warnings.rs create mode 100644 crates/bin/docs_rs_web/templates/header/abnormalities.html delete mode 100644 crates/bin/docs_rs_web/templates/header/global_alert.html create mode 100644 crates/lib/docs_rs_database/src/service_config/abnormalities.rs delete mode 100644 crates/lib/docs_rs_database/src/service_config/global_alert.rs diff --git a/Cargo.lock b/Cargo.lock index a5d183fd6..c36d42693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1905,6 +1905,7 @@ dependencies = [ "docs_rs_storage", "docs_rs_test_fakes", "docs_rs_types", + "docs_rs_uri", "docs_rs_utils", "futures-util", "pretty_assertions", @@ -1948,6 +1949,7 @@ dependencies = [ "docs_rs_storage", "docs_rs_test_fakes", "docs_rs_types", + "docs_rs_uri", "docs_rs_utils", "futures-util", "opentelemetry", @@ -2050,6 +2052,7 @@ dependencies = [ "docs_rs_opentelemetry", "docs_rs_registry_api", "docs_rs_types", + "docs_rs_uri", "docs_rs_utils", "futures-util", "hex", diff --git a/crates/bin/docs_rs_admin/Cargo.toml b/crates/bin/docs_rs_admin/Cargo.toml index cf7d7e48f..c7c2a55ae 100644 --- a/crates/bin/docs_rs_admin/Cargo.toml +++ b/crates/bin/docs_rs_admin/Cargo.toml @@ -20,6 +20,7 @@ docs_rs_logging = { path = "../../lib/docs_rs_logging" } docs_rs_repository_stats = { path = "../../lib/docs_rs_repository_stats" } docs_rs_storage = { path = "../../lib/docs_rs_storage" } docs_rs_types = { path = "../../lib/docs_rs_types" } +docs_rs_uri = { path = "../../lib/docs_rs_uri" } docs_rs_utils = { path = "../../lib/docs_rs_utils" } futures-util = { workspace = true } sqlx = { workspace = true } diff --git a/crates/bin/docs_rs_admin/src/main.rs b/crates/bin/docs_rs_admin/src/main.rs index 61412a81e..d45f466b8 100644 --- a/crates/bin/docs_rs_admin/src/main.rs +++ b/crates/bin/docs_rs_admin/src/main.rs @@ -4,7 +4,7 @@ mod repackage; pub(crate) mod testing; use anyhow::{Context as _, Result, bail}; -use chrono::NaiveDate; +use chrono::{NaiveDate, Utc}; use clap::{Parser, Subcommand}; use docs_rs_build_limits::{Overrides, blacklist}; use docs_rs_build_queue::priority::{ @@ -14,12 +14,13 @@ use docs_rs_build_queue::priority::{ use docs_rs_context::Context; use docs_rs_database::{ crate_details, - service_config::{AlertSeverity, ConfigName, GlobalAlert, remove_config, set_config}, + service_config::{Abnormality, AlertSeverity, AnchorId, ConfigName, remove_config, set_config}, }; use docs_rs_fastly::CdnBehaviour as _; use docs_rs_headers::SurrogateKey; use docs_rs_repository_stats::workspaces; use docs_rs_types::{CrateId, KrateName, ReleaseId, Version}; +use docs_rs_uri::EscapedURI; use futures_util::StreamExt; use rebuilds::queue_rebuilds_faulty_rustdoc; use std::iter; @@ -37,7 +38,7 @@ async fn main() -> Result<()> { Ok(()) } -#[derive(Debug, Clone, PartialEq, Eq, Parser)] +#[derive(Debug, Clone, PartialEq, Parser)] #[command( about = env!("CARGO_PKG_DESCRIPTION"), version = docs_rs_utils::BUILD_VERSION, @@ -350,7 +351,7 @@ impl BuildSubcommand { } } -#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +#[derive(Debug, Clone, PartialEq, Subcommand)] enum DatabaseSubcommand { /// Run database migration Migrate { @@ -359,10 +360,10 @@ enum DatabaseSubcommand { version: Option, }, - /// Manage the global alert shown in the site header - GlobalAlert { + /// Manage the abnormality shown in the site header + Abnormality { #[command(subcommand)] - command: GlobalAlertSubcommand, + command: AbnormalitySubcommand, }, /// temporary command to repackage missing crates into archive storage. @@ -410,7 +411,7 @@ impl DatabaseSubcommand { } .context("Failed to run database migrations")?, - Self::GlobalAlert { command } => command.handle_args(ctx).await?, + Self::Abnormality { command } => command.handle_args(ctx).await?, Self::Repackage { limit } => { let pool = ctx.pool()?; @@ -512,23 +513,26 @@ impl DatabaseSubcommand { } } -#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] -enum GlobalAlertSubcommand { - /// Set the global alert shown in the site header +#[derive(Debug, Clone, PartialEq, Subcommand)] +enum AbnormalitySubcommand { + /// Set the abnormality shown in the site header Set { #[arg(long)] - url: String, + url: EscapedURI, #[arg(long)] text: String, + /// explanation to be shown on the status page, can be HTML + #[arg(long)] + explanation: Option, #[arg(long, default_value_t)] severity: AlertSeverity, }, - /// Remove the global alert shown in the site header + /// Remove the abnormality shown in the site header Remove, } -impl GlobalAlertSubcommand { +impl AbnormalitySubcommand { async fn handle_args(self, ctx: Context) -> Result<()> { let mut conn = ctx .pool()? @@ -540,24 +544,28 @@ impl GlobalAlertSubcommand { Self::Set { url, text, + explanation, severity, } => { set_config( &mut conn, - ConfigName::GlobalAlert, - GlobalAlert { + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, url, text, + explanation, + start_time: Some(Utc::now()), severity, }, ) .await - .context("failed to set global alert in database")?; + .context("failed to set abnormality in database")?; } Self::Remove => { - remove_config(&mut conn, ConfigName::GlobalAlert) + remove_config(&mut conn, ConfigName::Abnormality) .await - .context("failed to remove global alert from database")?; + .context("failed to remove abnormality from database")?; } } diff --git a/crates/bin/docs_rs_web/src/handlers/about.rs b/crates/bin/docs_rs_web/src/handlers/about.rs index b678ca4cc..b39f9b27f 100644 --- a/crates/bin/docs_rs_web/src/handlers/about.rs +++ b/crates/bin/docs_rs_web/src/handlers/about.rs @@ -4,13 +4,13 @@ use crate::{ extractors::{DbConnection, Path}, impl_axum_webpage, page::templates::{AlertSeverityRender, RenderBrands, RenderSolid, filters}, + page::warnings::WarningsCache, }; use askama::Template; use axum::{extract::Extension, http::StatusCode, response::IntoResponse}; -use chrono::{DateTime, NaiveDate, Utc}; use docs_rs_build_limits::Limits; use docs_rs_context::Context; -use docs_rs_database::service_config::{ConfigName, get_config}; +use docs_rs_database::service_config::{Abnormality, ConfigName, get_config}; use std::sync::Arc; #[derive(Template)] @@ -32,16 +32,8 @@ impl_axum_webpage!( cache_policy = |_| CachePolicy::ShortInCdnAndBrowser, ); -#[derive(Debug, Clone, PartialEq, Eq)] -struct Abnormality { - title: &'static str, - explanation: &'static str, - start_time: Option>, -} - -#[derive(Template)] +#[derive(Debug, Clone, PartialEq, Template)] #[template(path = "core/about/status.html")] -#[derive(Debug, Clone, PartialEq, Eq)] struct AboutStatus { abnormalities: Vec, } @@ -64,6 +56,14 @@ pub(crate) async fn about_builds_handler( }) } +pub(crate) async fn status_handler( + Extension(warnings_cache): Extension>, +) -> AxumResult { + Ok(AboutStatus { + abnormalities: warnings_cache.get().await.abnormalities, + }) +} + macro_rules! about_page { ($ty:ident, $template:literal) => { #[derive(Template)] @@ -97,20 +97,6 @@ pub(crate) async fn about_handler(subpage: Option>) -> AxumResult AboutPageRedirection.into_response(), "download" => AboutPageDownload.into_response(), "rustdoc-json" => AboutPageRustdocJson.into_response(), - "status" => AboutStatus { - abnormalities: vec![ - Abnormality { - title: "Build queue is abnormally long", - explanation: "The build queue currently contains more than 100 crates, so it might take a while before new published crates get documented.", - start_time: Some(NaiveDate::from_ymd_opt(2023, 1, 30).unwrap().and_hms_opt(19, 32, 33).unwrap().and_utc()), - }, - Abnormality { - title: "High number of requests", - explanation: "There is a huge amount of HTTP requests currently, pages load might be slower than usual.", - start_time: None, - }, - ], - }.into_response(), _ => { let msg = "This /about page does not exist. \ Perhaps you are interested in creating it?"; @@ -132,6 +118,10 @@ mod tests { AxumResponseTestExt as _, AxumRouterTestExt, TestEnvironment, TestEnvironmentExt as _, }; use anyhow::Result; + use chrono::{TimeZone, Utc}; + use docs_rs_database::service_config::{AlertSeverity, AnchorId, set_config}; + use docs_rs_uri::EscapedURI; + use kuchikiki::traits::TendrilSink; #[tokio::test(flavor = "multi_thread")] async fn about_page() -> Result<()> { @@ -143,6 +133,7 @@ mod tests { let file_path = file?.path(); if file_path.extension() != Some(OsStr::new("html")) || file_path.file_stem() == Some(OsStr::new("index")) + || file_path.file_stem() == Some(OsStr::new("status")) { continue; } @@ -162,4 +153,102 @@ mod tests { web.assert_success("/about").await?; Ok(()) } + + #[tokio::test(flavor = "multi_thread")] + async fn about_status_page_renders_abnormality_details() -> Result<()> { + let env = TestEnvironment::new().await?; + + let mut conn = env.async_conn().await?; + set_config( + &mut conn, + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/maintenance" + .parse::() + .unwrap(), + text: "Scheduled maintenance".into(), + explanation: Some("Planned maintenance is in progress.".into()), + start_time: Some(Utc.with_ymd_and_hms(2023, 1, 30, 19, 32, 33).unwrap()), + severity: AlertSeverity::Warn, + }, + ) + .await?; + drop(conn); + + let web = env.web_app().await; + let page = + kuchikiki::parse_html().one(web.assert_success("/-/status/").await?.text().await?); + + let body_text = page.text_contents(); + assert!(body_text.contains("Scheduled maintenance")); + assert!(body_text.contains("Planned maintenance is in progress.")); + assert!(body_text.contains("2023-01-30 19:32:33 UTC")); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn about_status_page_shows_no_abnormalities_when_clean() -> Result<()> { + let env = TestEnvironment::new().await?; + let web = env.web_app().await; + + let page = + kuchikiki::parse_html().one(web.assert_success("/-/status/").await?.text().await?); + + let body_text = page.text_contents(); + assert!(body_text.contains("No abnormalities detected currently.")); + assert_eq!( + page.select(".about h3").unwrap().count(), + 0, + "should not render any abnormality headings" + ); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn about_status_page_renders_html_explanation() -> Result<()> { + let env = TestEnvironment::new().await?; + + let mut conn = env.async_conn().await?; + set_config( + &mut conn, + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/maintenance" + .parse::() + .unwrap(), + text: "Scheduled maintenance".into(), + explanation: Some( + "Planned maintenance is in progress. See details.".into(), + ), + start_time: None, + severity: AlertSeverity::Warn, + }, + ) + .await?; + drop(conn); + + let web = env.web_app().await; + let html = web.assert_success("/-/status/").await?.text().await?; + let page = kuchikiki::parse_html().one(html.clone()); + + // The tag should be rendered as an actual HTML element, not escaped. + assert!( + html.contains("in progress"), + "HTML in explanation should be rendered unescaped" + ); + + // The tag should be rendered as an actual link. + let link = page + .select(".about p a[href='/details']") + .unwrap() + .next() + .expect("explanation should contain a rendered link"); + assert!(link.text_contents().contains("details")); + + Ok(()) + } } diff --git a/crates/bin/docs_rs_web/src/handlers/mod.rs b/crates/bin/docs_rs_web/src/handlers/mod.rs index d391b059b..29ab5e0db 100644 --- a/crates/bin/docs_rs_web/src/handlers/mod.rs +++ b/crates/bin/docs_rs_web/src/handlers/mod.rs @@ -15,7 +15,7 @@ pub(crate) mod status; use crate::Config; use crate::metrics::WebMetrics; use crate::middleware::{csp, security}; -use crate::page::{self, TemplateData, global_alert::GlobalAlertCache}; +use crate::page::{self, TemplateData, warnings::WarningsCache}; use crate::{cache, routes}; use anyhow::{Context as _, Error, Result, anyhow, bail}; use axum::{ @@ -75,9 +75,8 @@ async fn apply_middleware( template_data: Option>, ) -> Result { let has_templates = template_data.is_some(); - let global_alert_cache = Arc::new( - GlobalAlertCache::new(context.pool()?.clone(), context.build_queue()?.clone()).await, - ); + let abnormality_cache = + Arc::new(WarningsCache::new(context.pool()?.clone(), context.build_queue()?.clone()).await); let web_metrics = Arc::new(WebMetrics::new(&context.meter_provider)); @@ -106,7 +105,7 @@ async fn apply_middleware( .layer(Extension(config.clone())) .layer(Extension(context.registry_api()?.clone())) .layer(Extension(context.storage()?.clone())) - .layer(Extension(global_alert_cache)) + .layer(Extension(abnormality_cache)) .layer(option_layer(template_data.map(Extension))) .layer(middleware::from_fn(csp::csp_middleware)) .layer(option_layer(has_templates.then_some(middleware::from_fn( @@ -236,8 +235,11 @@ mod tests { async_wrapper, }; use docs_rs_config::AppConfig as _; - use docs_rs_database::service_config::{AlertSeverity, ConfigName, GlobalAlert, set_config}; + use docs_rs_database::service_config::{ + Abnormality, AlertSeverity, AnchorId, ConfigName, set_config, + }; use docs_rs_types::{DocCoverage, KrateName, ReleaseId, Version, testing::V1}; + use docs_rs_uri::EscapedURI; use kuchikiki::traits::TendrilSink; use pretty_assertions::assert_eq; use std::str::FromStr; @@ -270,7 +272,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn test_global_alert_is_not_rendered_when_unset() -> Result<()> { + async fn test_abnormality_is_not_rendered_when_unset() -> Result<()> { let env = TestEnvironment::new().await?; let web = env.web_app().await; @@ -281,18 +283,23 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn test_manual_global_alert_renders_configured_link() -> Result<()> { + async fn test_manual_abnormality_renders_configured_link() -> Result<()> { let env = TestEnvironment::new().await?; let mut conn = env.async_conn().await?; - // NOTE: the global alert is cached inside the web-app, so we have to - // set it before we fetch the web-app from the test-environments. + // NOTE: abnormalities are cached inside the web-app, so set them + // before we fetch the web-app from the test-environments. set_config( &mut conn, - ConfigName::GlobalAlert, - GlobalAlert { - url: "https://example.com/maintenance".into(), + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/maintenance" + .parse::() + .unwrap(), text: "Scheduled maintenance".into(), + explanation: Some("Planned maintenance is in progress.".into()), + start_time: None, severity: AlertSeverity::Warn, }, ) @@ -304,20 +311,20 @@ mod tests { .select("a.pure-menu-link.warn") .unwrap() .next() - .expect("missing global alert"); + .expect("missing abnormality"); assert_eq!( alert.attributes.borrow().get("href"), - Some("https://example.com/maintenance") + Some("/-/status/#manual") ); assert!(alert.text_contents().contains("Scheduled maintenance")); Ok(()) } #[tokio::test(flavor = "multi_thread")] - async fn test_queue_alert_is_rendered_when_threshold_is_exceeded() -> Result<()> { + async fn test_queue_abnormality_is_rendered_when_threshold_is_exceeded() -> Result<()> { let mut queue_config = docs_rs_build_queue::Config::test_config()?; - queue_config.public_alert_threshold = 1; + queue_config.length_warning_threshold = 1; let env = TestEnvironment::builder() .build_queue_config(queue_config) .build() @@ -339,16 +346,16 @@ mod tests { assert_eq!( alert.attributes.borrow().get("href"), - Some("/releases/queue") + Some("/-/status/#queue-length") ); assert!(alert.text_contents().contains("long build queue")); Ok(()) } #[tokio::test(flavor = "multi_thread")] - async fn test_manual_alert_wins_when_multiple_alerts_are_active() -> Result<()> { + async fn test_manual_abnormality_wins_when_multiple_abnormalities_are_active() -> Result<()> { let mut queue_config = docs_rs_build_queue::Config::test_config()?; - queue_config.public_alert_threshold = 1; + queue_config.length_warning_threshold = 1; let env = TestEnvironment::builder() .build_queue_config(queue_config) .build() @@ -357,10 +364,15 @@ mod tests { let mut conn = env.async_conn().await?; set_config( &mut conn, - ConfigName::GlobalAlert, - GlobalAlert { - url: "https://example.com/maintenance".into(), + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/maintenance" + .parse::() + .unwrap(), text: "Scheduled maintenance".into(), + explanation: Some("Planned maintenance is in progress.".into()), + start_time: None, severity: AlertSeverity::Error, }, ) @@ -395,12 +407,13 @@ mod tests { .collect::>(); assert!(dropdown_links.iter().any(|(href, text)| { - href == "https://example.com/maintenance" && text.contains("Scheduled maintenance") + href == "/-/status/#manual" && text.contains("Scheduled maintenance") })); assert!( dropdown_links .iter() - .any(|(href, text)| href == "/releases/queue" && text.contains("long build queue")) + .any(|(href, text)| href == "/-/status/#queue-length" + && text.contains("long build queue")) ); Ok(()) } diff --git a/crates/bin/docs_rs_web/src/handlers/rustdoc.rs b/crates/bin/docs_rs_web/src/handlers/rustdoc.rs index 771ec5fc5..6a5cbf849 100644 --- a/crates/bin/docs_rs_web/src/handlers/rustdoc.rs +++ b/crates/bin/docs_rs_web/src/handlers/rustdoc.rs @@ -15,9 +15,9 @@ use crate::{ metrics::WebMetrics, middleware::csp::Csp, page::{ - ActiveAlerts, TemplateData, - global_alert::GlobalAlertCache, + TemplateData, templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, + warnings::{ActiveWarnings, WarningsCache}, }, utils::{self, licenses}, }; @@ -432,6 +432,7 @@ impl From for LimitedCrateDetails { pub struct RustdocPage { pub latest_path: EscapedURI, pub permalink_path: EscapedURI, + pub warnings: ActiveWarnings, // true if we are displaying the latest version of the crate, regardless // of whether the URL specifies a version number or the string "latest." pub is_latest_version: bool, @@ -492,7 +493,6 @@ impl RustdocPage { rustdoc_html: StreamingBlob, max_parse_memory: usize, if_none_match: Option<&IfNoneMatch>, - alerts: ActiveAlerts, ) -> AxumResponse { let crate_name = &self.metadata.name; @@ -530,7 +530,6 @@ impl RustdocPage { template_data, rustdoc_html.content, max_parse_memory, - alerts, self.clone(), otel_metrics, )), @@ -557,7 +556,7 @@ pub(crate) async fn rustdoc_html_server_handler( Extension(storage): Extension>, Extension(config): Extension>, Extension(csp): Extension>, - Extension(global_alert_cache): Extension>, + Extension(warnings_cache): Extension>, RawQuery(original_query): RawQuery, if_none_match: Option>, mut conn: DbConnection, @@ -763,9 +762,11 @@ pub(crate) async fn rustdoc_html_server_handler( let current_target = params.doc_target_or_default().unwrap_or_default(); // Build the page of documentation, + let warnings = warnings_cache.get().await; let page = Arc::new(RustdocPage { latest_path, permalink_path, + warnings, is_latest_version, is_latest_url: params.req_version().is_latest(), is_prerelease, @@ -781,7 +782,6 @@ pub(crate) async fn rustdoc_html_server_handler( blob, config.max_parse_memory, if_none_match.as_deref(), - global_alert_cache.get().await, ) .await) } @@ -1074,7 +1074,9 @@ mod test { use anyhow::{Context, Result}; use chrono::{NaiveDate, Utc}; use docs_rs_cargo_metadata::Dependency; - use docs_rs_database::service_config::{AlertSeverity, ConfigName, GlobalAlert, set_config}; + use docs_rs_database::service_config::{ + Abnormality, AlertSeverity, AnchorId, ConfigName, set_config, + }; use docs_rs_registry_api::{CrateOwner, OwnerKind}; use docs_rs_rustdoc_json::{ RUSTDOC_JSON_COMPRESSION_ALGORITHMS, read_format_version_from_rustdoc_json, @@ -1084,6 +1086,7 @@ mod test { Version, testing::{KRATE, V2}, }; + use docs_rs_uri::EscapedURI; use docs_rs_uri::encode_url_path; use kuchikiki::traits::TendrilSink; use pretty_assertions::assert_eq; @@ -1141,18 +1144,23 @@ mod test { } #[tokio::test(flavor = "multi_thread")] - async fn rustdoc_page_renders_global_alert() -> Result<()> { + async fn rustdoc_page_renders_abnormality() -> Result<()> { let env = TestEnvironment::new().await?; let mut conn = env.async_conn().await?; - // NOTE: the global alert is cached inside the web-app, so we have to - // set it before we fetch the web-app from the test-environments. + // NOTE: abnormalities are cached inside the web-app, so set them + // before we fetch the web-app from the test-environments. set_config( &mut conn, - ConfigName::GlobalAlert, - GlobalAlert { - url: "https://example.com/maintenance".into(), + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/maintenance" + .parse::() + .unwrap(), text: "Scheduled maintenance".into(), + explanation: Some("Planned maintenance is in progress.".into()), + start_time: None, severity: AlertSeverity::Warn, }, ) @@ -1179,11 +1187,11 @@ mod test { .select("a.pure-menu-link.warn") .expect("invalid selector") .next() - .context("missing global alert on rustdoc page")?; + .context("missing abnormality on rustdoc page")?; assert_eq!( alert.attributes.borrow().get("href"), - Some("https://example.com/maintenance") + Some("/-/status/#manual") ); assert!(alert.text_contents().contains("Scheduled maintenance")); Ok(()) diff --git a/crates/bin/docs_rs_web/src/page/global_alert.rs b/crates/bin/docs_rs_web/src/page/global_alert.rs deleted file mode 100644 index e15f5ea17..000000000 --- a/crates/bin/docs_rs_web/src/page/global_alert.rs +++ /dev/null @@ -1,180 +0,0 @@ -use anyhow::{Context as _, Result}; -use docs_rs_build_queue::AsyncBuildQueue; -use docs_rs_database::{ - Pool, - service_config::{ConfigName, GlobalAlert, get_config}, -}; -use std::{sync::Arc, time::Duration}; -use tokio::{ - sync::RwLock, - task::JoinHandle, - time::{MissedTickBehavior, interval}, -}; -use tracing::{debug, error}; - -pub(crate) type ActiveAlerts = Vec; - -#[derive(Debug)] -struct State { - snapshot: ActiveAlerts, -} - -#[derive(Debug)] -pub(crate) struct GlobalAlertCache { - background_task: JoinHandle<()>, - state: Arc>, -} - -impl GlobalAlertCache { - const TTL: Duration = Duration::from_secs(300); // 5 minutes - - pub(crate) async fn new(pool: Pool, build_queue: Arc) -> Self { - Self::new_with_ttl(pool, build_queue, Self::TTL).await - } - - async fn new_with_ttl(pool: Pool, build_queue: Arc, ttl: Duration) -> Self { - let initial_snapshot = match Self::load_from_sources(&pool, &build_queue).await { - Ok(snapshot) => snapshot, - Err(err) => { - error!(?err, "failed to load initial alerts snapshot"); - ActiveAlerts::default() - } - }; - - let state = Arc::new(RwLock::new(State { - snapshot: initial_snapshot, - })); - let refresh_state = Arc::clone(&state); - - let background_task = tokio::spawn(async move { - let mut refresh_interval = interval(ttl); - refresh_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); - - // Consume the immediate tick because we already did the initial load. - refresh_interval.tick().await; - - loop { - refresh_interval.tick().await; - - debug!("loading alerts snapshot"); - match Self::load_from_sources(&pool, &build_queue).await { - Ok(snapshot) => { - let mut state = refresh_state.write().await; - state.snapshot = snapshot; - } - Err(err) => { - error!( - ?err, - "failed to refresh alerts snapshot, keeping cached value" - ); - } - } - } - }); - - Self { - state, - background_task, - } - } - - async fn load_from_sources(pool: &Pool, build_queue: &AsyncBuildQueue) -> Result { - let mut conn = pool - .get_async() - .await - .context("failed to get DB connection for alerts")?; - - let mut active_alerts = ActiveAlerts::new(); - - if let Some(alert) = get_config::(&mut conn, ConfigName::GlobalAlert) - .await - .context("failed to load manual global alert from config")? - { - active_alerts.push(alert); - } - - active_alerts.extend( - build_queue - .gather_alerts() - .await - .context("failed to load build queue alerts")?, - ); - - Ok(active_alerts) - } - - pub(crate) async fn get(&self) -> ActiveAlerts { - self.state.read().await.snapshot.clone() - } -} - -impl Drop for GlobalAlertCache { - fn drop(&mut self) { - self.background_task.abort(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::testing::TestEnvironment; - use anyhow::Result; - use docs_rs_database::service_config::{AlertSeverity, set_config}; - use tokio::time::sleep; - - #[tokio::test(flavor = "multi_thread")] - async fn cache_loads_immediately_and_keeps_previous_value_on_reload_failure() -> Result<()> { - let env = TestEnvironment::new().await?; - - let mut conn = env.async_conn().await?; - set_config( - &mut conn, - ConfigName::GlobalAlert, - GlobalAlert { - url: "https://example.com/maintenance".into(), - text: "Scheduled maintenance".into(), - severity: AlertSeverity::Warn, - }, - ) - .await?; - drop(conn); - - let cache = GlobalAlertCache::new_with_ttl( - env.pool()?.clone(), - env.build_queue()?.clone(), - Duration::from_millis(25), - ) - .await; - - assert_eq!( - cache.get().await.first().map(|alert| alert.text.as_str()), - Some("Scheduled maintenance") - ); - - let mut conn = env.async_conn().await?; - sqlx::query!( - "UPDATE config SET value = $2 WHERE name = $1", - "global_alert", - serde_json::json!({ - "url": 1, - "text": false - }), - ) - .execute(&mut *conn) - .await?; - drop(conn); - - sleep(Duration::from_millis(75)).await; - - assert_eq!( - cache.get().await.first(), - Some(&GlobalAlert { - url: "https://example.com/maintenance".into(), - text: "Scheduled maintenance".into(), - severity: AlertSeverity::Warn, - }) - ); - - Ok(()) - } -} diff --git a/crates/bin/docs_rs_web/src/page/mod.rs b/crates/bin/docs_rs_web/src/page/mod.rs index f19799653..4c9c20b83 100644 --- a/crates/bin/docs_rs_web/src/page/mod.rs +++ b/crates/bin/docs_rs_web/src/page/mod.rs @@ -1,6 +1,6 @@ -pub(crate) mod global_alert; pub(crate) mod templates; +pub(crate) mod warnings; pub(crate) mod web_page; -pub(crate) use global_alert::ActiveAlerts; pub(crate) use templates::TemplateData; +pub(crate) use warnings::ActiveWarnings; diff --git a/crates/bin/docs_rs_web/src/page/templates.rs b/crates/bin/docs_rs_web/src/page/templates.rs index 466c71670..20e54f049 100644 --- a/crates/bin/docs_rs_web/src/page/templates.rs +++ b/crates/bin/docs_rs_web/src/page/templates.rs @@ -270,7 +270,7 @@ impl RenderBrands for T { } } -/// how to render the Severity for a GlobalAlert +/// how to render the severity for an abnormality pub(crate) trait AlertSeverityRender { fn css_class(&self) -> &'static str; fn render_icon_solid(&self, fw: bool, spin: bool, extra: &str) diff --git a/crates/bin/docs_rs_web/src/page/warnings.rs b/crates/bin/docs_rs_web/src/page/warnings.rs new file mode 100644 index 000000000..5824a7564 --- /dev/null +++ b/crates/bin/docs_rs_web/src/page/warnings.rs @@ -0,0 +1,341 @@ +use anyhow::{Context as _, Result}; +use chrono::Utc; +use docs_rs_build_queue::AsyncBuildQueue; +use docs_rs_database::{ + Pool, + service_config::{Abnormality, ConfigName, get_config}, +}; +use serde::Serialize; +use std::{sync::Arc, time::Duration}; +use tokio::{ + sync::RwLock, + task::JoinHandle, + time::{MissedTickBehavior, interval}, +}; +use tracing::{debug, error}; + +pub(crate) type ActiveAbnormalities = Vec; + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ActiveWarnings { + pub(crate) abnormalities: ActiveAbnormalities, +} + +/// cache for warning items to be shown on mane pages. +/// * abnormalities (long build queue, cpu usage / response times, ...) +/// * later alerts / notifications (to be discarded by the user) +#[derive(Debug)] +pub(crate) struct WarningsCache { + background_task: JoinHandle<()>, + state: Arc>, +} + +impl WarningsCache { + const TTL: Duration = Duration::from_secs(300); // 5 minutes + + pub(crate) async fn new(pool: Pool, build_queue: Arc) -> Self { + Self::new_with_ttl(pool, build_queue, Self::TTL).await + } + + async fn new_with_ttl(pool: Pool, build_queue: Arc, ttl: Duration) -> Self { + async fn load_abnormalities( + pool: &Pool, + build_queue: &AsyncBuildQueue, + previous_snapshot: &[Abnormality], + ) -> Option { + match WarningsCache::load_abnormalities(pool, build_queue, previous_snapshot).await { + Ok(snapshot) => Some(snapshot), + Err(err) => { + error!(?err, "failed to load abnormalities"); + None + } + } + } + + let initial_abnormalities = load_abnormalities(&pool, &build_queue, &[]) + .await + .unwrap_or_default(); + + let state = Arc::new(RwLock::new(ActiveWarnings { + abnormalities: initial_abnormalities, + })); + let refresh_state = Arc::clone(&state); + + let background_task = tokio::spawn(async move { + let mut refresh_interval = interval(ttl); + refresh_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + // Consume the immediate tick because we already did the initial load. + refresh_interval.tick().await; + + loop { + refresh_interval.tick().await; + + debug!("loading alerts snapshot"); + let previous_abnormalities = refresh_state.read().await.abnormalities.clone(); + + if let Some(abnormalities) = + load_abnormalities(&pool, &build_queue, &previous_abnormalities).await + { + let mut state = refresh_state.write().await; + state.abnormalities = abnormalities; + } + } + }); + + Self { + state, + background_task, + } + } + + async fn load_abnormalities( + pool: &Pool, + build_queue: &AsyncBuildQueue, + previous_abnormalities: &[Abnormality], + ) -> Result { + let mut conn = pool + .get_async() + .await + .context("failed to get DB connection for alerts")?; + + let mut active_abnormalities = ActiveAbnormalities::new(); + + if let Some(abnormality) = get_config::(&mut conn, ConfigName::Abnormality) + .await + .context("failed to load manual abnormality from config")? + { + active_abnormalities.push(abnormality); + } + + let mut queue_abnormalities = build_queue + .gather_alerts() + .await + .context("failed to load build queue abnormalities")?; + for abnormality in &mut queue_abnormalities { + Self::assign_start_time(abnormality, previous_abnormalities); + } + active_abnormalities.extend(queue_abnormalities); + + Ok(active_abnormalities) + } + + fn same_abnormality(left: &Abnormality, right: &Abnormality) -> bool { + left.anchor_id == right.anchor_id + } + + fn assign_start_time(abnormality: &mut Abnormality, previous_snapshot: &[Abnormality]) { + if abnormality.start_time.is_some() { + return; + } + + abnormality.start_time = previous_snapshot + .iter() + .find(|previous| Self::same_abnormality(previous, abnormality)) + .and_then(|previous| previous.start_time) + .or_else(|| Some(Utc::now())); + } + + pub(crate) async fn get(&self) -> ActiveWarnings { + self.state.read().await.clone() + } +} + +impl Drop for WarningsCache { + fn drop(&mut self) { + self.background_task.abort(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::TestEnvironment; + use anyhow::Result; + use docs_rs_config::AppConfig as _; + use docs_rs_database::service_config::{AlertSeverity, AnchorId, set_config}; + use docs_rs_uri::EscapedURI; + use tokio::time::sleep; + + #[tokio::test(flavor = "multi_thread")] + async fn cache_loads_immediately_and_keeps_previous_value_on_reload_failure() -> Result<()> { + let env = TestEnvironment::new().await?; + + let mut conn = env.async_conn().await?; + set_config( + &mut conn, + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/maintenance" + .parse::() + .unwrap(), + text: "Scheduled maintenance".into(), + explanation: Some("Planned maintenance is in progress.".into()), + start_time: None, + severity: AlertSeverity::Warn, + }, + ) + .await?; + drop(conn); + + let cache = WarningsCache::new_with_ttl( + env.pool()?.clone(), + env.build_queue()?.clone(), + Duration::from_millis(25), + ) + .await; + + assert_eq!( + cache + .get() + .await + .abnormalities + .first() + .map(|alert| alert.text.as_str()), + Some("Scheduled maintenance") + ); + + let mut conn = env.async_conn().await?; + sqlx::query!( + "UPDATE config SET value = $2 WHERE name = $1", + "abnormality", + serde_json::json!({ + "url": 1, + "text": false + }), + ) + .execute(&mut *conn) + .await?; + drop(conn); + + sleep(Duration::from_millis(75)).await; + + assert_eq!( + cache + .get() + .await + .abnormalities + .first() + .map(|abnormality| abnormality.text.as_str()), + Some("Scheduled maintenance") + ); + assert_eq!( + cache + .get() + .await + .abnormalities + .first() + .map(|abnormality| &abnormality.anchor_id), + Some(&AnchorId::Manual) + ); + assert_eq!( + cache + .get() + .await + .abnormalities + .first() + .and_then(|abnormality| abnormality.start_time), + None + ); + + Ok(()) + } + + #[test] + fn same_abnormality_uses_anchor_id_only() { + let left = Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/one".parse::().unwrap(), + text: "first text".into(), + explanation: Some("first explanation".into()), + start_time: None, + severity: AlertSeverity::Warn, + }; + let right = Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/two".parse::().unwrap(), + text: "second text".into(), + explanation: None, + start_time: None, + severity: AlertSeverity::Error, + }; + + assert!(WarningsCache::same_abnormality(&left, &right)); + } + + #[test] + fn same_abnormality_returns_false_for_different_anchor_ids() { + let left = Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com".parse::().unwrap(), + text: "same text".into(), + explanation: Some("same explanation".into()), + start_time: None, + severity: AlertSeverity::Warn, + }; + let right = Abnormality { + anchor_id: AnchorId::QueueLength, + url: "https://example.com".parse::().unwrap(), + text: "same text".into(), + explanation: Some("same explanation".into()), + start_time: None, + severity: AlertSeverity::Warn, + }; + + assert!(!WarningsCache::same_abnormality(&left, &right)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn cache_preserves_queue_start_time_across_refreshes() -> Result<()> { + let mut queue_config = docs_rs_build_queue::Config::test_config()?; + queue_config.length_warning_threshold = 1; + let env = crate::testing::TestEnvironment::builder() + .build_queue_config(queue_config) + .build() + .await?; + + let queue = env.build_queue()?.clone(); + for idx in 0..2 { + let name = format!("queued-crate-{idx}").parse::()?; + queue + .add_crate(&name, &docs_rs_types::Version::parse("1.0.0")?, 0, None) + .await?; + } + + let cache = WarningsCache::new_with_ttl( + env.pool()?.clone(), + env.build_queue()?.clone(), + Duration::from_millis(25), + ) + .await; + + let first_snapshot = cache.get().await; + let first_start_time = first_snapshot + .abnormalities + .iter() + .find(|a| a.anchor_id == AnchorId::QueueLength) + .expect("missing queue-length abnormality on first load") + .start_time + .expect("queue-length abnormality should have a start_time"); + + // Wait for at least one cache refresh cycle. + sleep(Duration::from_millis(75)).await; + + let second_snapshot = cache.get().await; + let second_start_time = second_snapshot + .abnormalities + .iter() + .find(|a| a.anchor_id == AnchorId::QueueLength) + .expect("missing queue-length abnormality after refresh") + .start_time + .expect("queue-length abnormality should still have a start_time"); + + assert_eq!( + first_start_time, second_start_time, + "start_time should be preserved across cache refreshes" + ); + + Ok(()) + } +} diff --git a/crates/bin/docs_rs_web/src/page/web_page.rs b/crates/bin/docs_rs_web/src/page/web_page.rs index be442e0e7..2e457ba70 100644 --- a/crates/bin/docs_rs_web/src/page/web_page.rs +++ b/crates/bin/docs_rs_web/src/page/web_page.rs @@ -1,7 +1,10 @@ use crate::{ error::AxumNope, middleware::csp::Csp, - page::{ActiveAlerts, global_alert::GlobalAlertCache, templates::TemplateData}, + page::{ + templates::TemplateData, + warnings::{ActiveWarnings, WarningsCache}, + }, }; use axum::{ body::Body, @@ -17,7 +20,7 @@ pub(crate) trait AddTemplateValues: IntoResponse { fn render_with_template_values( &mut self, csp_nonce: String, - alerts: ActiveAlerts, + warnings: ActiveWarnings, ) -> askama::Result; } @@ -36,11 +39,11 @@ macro_rules! impl_axum_webpage { fn render_with_template_values( &mut self, csp_nonce: String, - alerts: $crate::page::ActiveAlerts, + warnings: $crate::page::ActiveWarnings, ) -> askama::Result { let values: std::collections::HashMap<&str, &dyn std::any::Any> = [ ("csp_nonce", &csp_nonce as &dyn std::any::Any), - ("alerts", &alerts as &dyn std::any::Any), + ("warnings", &warnings as &dyn std::any::Any), ] .into_iter() .collect(); @@ -120,7 +123,7 @@ fn render_response( mut response: AxumResponse, templates: Arc, csp_nonce: String, - alerts: ActiveAlerts, + warnings: ActiveWarnings, ) -> BoxFuture<'static, AxumResponse> { async move { if let Some(render) = response.extensions_mut().remove::() { @@ -130,19 +133,19 @@ fn render_response( } = render; let mut template = Arc::into_inner(template).unwrap(); let csp_nonce_clone = csp_nonce.clone(); - let alerts_clone = alerts.clone(); + let warnings_clone = warnings.clone(); let result: Result = if cpu_intensive_rendering { templates .render_in_threadpool(move || { template - .render_with_template_values(csp_nonce_clone, alerts_clone) + .render_with_template_values(csp_nonce_clone, warnings_clone) .map_err(|err| err.into()) }) .await } else { template - .render_with_template_values(csp_nonce_clone, alerts_clone) + .render_with_template_values(csp_nonce_clone, warnings_clone) .map_err(|err| err.into()) }; @@ -157,7 +160,7 @@ fn render_response( AxumNope::InternalError(err).into_response(), templates, csp_nonce, - alerts, + warnings, ) .await; } @@ -190,14 +193,14 @@ pub(crate) async fn render_templates_middleware(req: AxumRequest, next: Next) -> .nonce() .to_owned(); - let alerts = req + let warnings = req .extensions() - .get::>() - .expect("global alert cache request extension not found") + .get::>() + .expect("warnings cache request extension not found") .get() .await; let response = next.run(req).await; - render_response(response, templates, csp_nonce, alerts).await + render_response(response, templates, csp_nonce, warnings).await } diff --git a/crates/bin/docs_rs_web/src/routes.rs b/crates/bin/docs_rs_web/src/routes.rs index 8fef55986..7638f2aea 100644 --- a/crates/bin/docs_rs_web/src/routes.rs +++ b/crates/bin/docs_rs_web/src/routes.rs @@ -143,6 +143,7 @@ pub(crate) fn build_axum_routes() -> Result { "/-/sitemap/{letter}/sitemap.xml", get_internal(sitemap::sitemap_handler), ) + .route_with_tsr("/-/status/", get_internal(about::status_handler)) .route_with_tsr("/about/builds", get_internal(about::about_builds_handler)) .route_with_tsr("/about", get_internal(about::about_handler)) .route_with_tsr("/about/{subpage}", get_internal(about::about_handler)) diff --git a/crates/bin/docs_rs_web/src/utils/html_rewrite.rs b/crates/bin/docs_rs_web/src/utils/html_rewrite.rs index cbd526fea..d177a595a 100644 --- a/crates/bin/docs_rs_web/src/utils/html_rewrite.rs +++ b/crates/bin/docs_rs_web/src/utils/html_rewrite.rs @@ -2,7 +2,7 @@ use crate::{ handlers::rustdoc::RustdocPage, metrics::WebMetrics, page::{ - ActiveAlerts, TemplateData, + TemplateData, templates::{Body, Head, Vendored}, }, }; @@ -39,7 +39,6 @@ pub(crate) fn rewrite_rustdoc_html_stream( template_data: Arc, mut reader: R, max_allowed_memory_usage: usize, - alerts: ActiveAlerts, data: Arc, otel_metrics: Arc, ) -> impl Stream> + Send + 'static @@ -71,7 +70,7 @@ where let vendored_html = Vendored.render().unwrap(); let body_html = Body.render().unwrap(); let values: HashMap<&str, &dyn Any> = - HashMap::from_iter([("alerts", &alerts as &dyn Any)]); + HashMap::from_iter([("warnings", &data.warnings as &dyn Any)]); let topbar_html = data.render_with_values(&values).unwrap(); // Before: ... rustdoc content ... diff --git a/crates/bin/docs_rs_web/templates/core/about/status.html b/crates/bin/docs_rs_web/templates/core/about/status.html index 5548ba22a..6d07ff957 100644 --- a/crates/bin/docs_rs_web/templates/core/about/status.html +++ b/crates/bin/docs_rs_web/templates/core/about/status.html @@ -11,11 +11,19 @@

    Docs.rs status

    No abnormalities detected currently.

    {%- else %} {%- for abnormality in abnormalities %} -

    {{abnormality.title}}

    {#- -#} -

    {{abnormality.explanation}}

    +

    + {{- abnormality.severity.render_icon_solid(false, false, "") }} + {{ abnormality.text }} +

    + {%- if let Some(explanation) = &abnormality.explanation %} +

    {{ explanation|safe }}

    + {%- endif %} {%- if let Some(start_time) = abnormality.start_time %} -

    This abnormality is ongoing since the {{start_time.format("%Y-%m-%d %H:%M:%S").to_string() }}.

    +

    This is ongoing since {{ start_time.format("%Y-%m-%d %H:%M:%S UTC") }}.

    {%- endif %} +

    + More details here. +

    {%- endfor %} {%- endif %}
    diff --git a/crates/bin/docs_rs_web/templates/header/abnormalities.html b/crates/bin/docs_rs_web/templates/header/abnormalities.html new file mode 100644 index 000000000..5502dc892 --- /dev/null +++ b/crates/bin/docs_rs_web/templates/header/abnormalities.html @@ -0,0 +1,27 @@ +{%- let abnormalities = askama::get_value::("warnings").unwrap().abnormalities -%} + +{%- if abnormalities.len() == 1 -%} +
  • + {% let abnormality = abnormalities.first().unwrap() %} + + {{- abnormality.severity.render_icon_solid(false, false, "") }} + {{ abnormality.text -}} + +
  • +{%- elif abnormalities.len() >= 2 -%} + {% let first = abnormalities.first().unwrap() %} +
  • + + {{- first.severity.render_icon_solid(false, false, "") }} {{ first.text -}} + + +
  • +{% endif %} diff --git a/crates/bin/docs_rs_web/templates/header/global_alert.html b/crates/bin/docs_rs_web/templates/header/global_alert.html deleted file mode 100644 index f848b83ff..000000000 --- a/crates/bin/docs_rs_web/templates/header/global_alert.html +++ /dev/null @@ -1,29 +0,0 @@ -{%- let alerts = askama::get_value::("alerts").unwrap() -%} - -{%- if alerts.len() == 1 -%} -
  • - {% let alert = alerts.first().unwrap() %} - - {{- alert.severity.render_icon_solid(false, false, "") }} - {{ alert.text -}} - -
  • -{%- elif alerts.len() >= 2 -%} - {% let first = alerts.first().unwrap() %} - -{% endif %} diff --git a/crates/bin/docs_rs_web/templates/header/topbar_end.html b/crates/bin/docs_rs_web/templates/header/topbar_end.html index 6be3a52b5..722d9695d 100644 --- a/crates/bin/docs_rs_web/templates/header/topbar_end.html +++ b/crates/bin/docs_rs_web/templates/header/topbar_end.html @@ -1,8 +1,8 @@ {%- import "macros.html" as macros -%}
    - {# The global alert, if there is one #} - {% include "header/global_alert.html" -%} + {# The current abnormalities, if there are any #} + {% include "header/abnormalities.html" -%}
      {# diff --git a/crates/lib/docs_rs_build_queue/Cargo.toml b/crates/lib/docs_rs_build_queue/Cargo.toml index 2bd5da10c..f1ec57d57 100644 --- a/crates/lib/docs_rs_build_queue/Cargo.toml +++ b/crates/lib/docs_rs_build_queue/Cargo.toml @@ -21,6 +21,7 @@ docs_rs_env_vars = { path = "../docs_rs_env_vars" } docs_rs_opentelemetry = { path = "../docs_rs_opentelemetry" } docs_rs_repository_stats = { path = "../docs_rs_repository_stats" } docs_rs_types = { path = "../docs_rs_types" } +docs_rs_uri = { path = "../docs_rs_uri" } docs_rs_utils = { path = "../docs_rs_utils" } futures-util = { workspace = true } opentelemetry = { workspace = true } diff --git a/crates/lib/docs_rs_build_queue/src/config.rs b/crates/lib/docs_rs_build_queue/src/config.rs index 878489ded..fbbe5855e 100644 --- a/crates/lib/docs_rs_build_queue/src/config.rs +++ b/crates/lib/docs_rs_build_queue/src/config.rs @@ -8,7 +8,7 @@ pub struct Config { pub build_attempts: u16, pub deprioritize_workspace_size: u16, pub delay_between_build_attempts: Duration, - pub public_alert_threshold: usize, + pub length_warning_threshold: usize, } impl Default for Config { @@ -17,7 +17,7 @@ impl Default for Config { build_attempts: 5, deprioritize_workspace_size: 20, delay_between_build_attempts: Duration::from_secs(60), - public_alert_threshold: 1000, + length_warning_threshold: 1000, } } } @@ -38,8 +38,8 @@ impl AppConfig for Config { config.deprioritize_workspace_size = size; } - if let Some(length) = maybe_env::("DOCSRS_QUEUE_PUBLIC_ALERT_THRESHOLD")? { - config.public_alert_threshold = length; + if let Some(length) = maybe_env::("DOCSRS_QUEUE_LENGTH_WARNING_THRESHOLD")? { + config.length_warning_threshold = length; } Ok(config) diff --git a/crates/lib/docs_rs_build_queue/src/queue/non_blocking.rs b/crates/lib/docs_rs_build_queue/src/queue/non_blocking.rs index 3fd3934f0..990e8661b 100644 --- a/crates/lib/docs_rs_build_queue/src/queue/non_blocking.rs +++ b/crates/lib/docs_rs_build_queue/src/queue/non_blocking.rs @@ -5,11 +5,12 @@ use crate::{ use anyhow::{Context as _, Result}; use docs_rs_database::{ Pool, - service_config::{AlertSeverity, ConfigName, GlobalAlert, get_config, set_config}, + service_config::{Abnormality, AlertSeverity, AnchorId, ConfigName, get_config, set_config}, }; use docs_rs_opentelemetry::AnyMeterProvider; use docs_rs_repository_stats::workspaces; use docs_rs_types::{KrateName, Version}; +use docs_rs_uri::EscapedURI; use futures_util::TryStreamExt as _; use std::{ collections::{HashMap, HashSet}, @@ -271,11 +272,11 @@ impl AsyncBuildQueue { queued_crates .filter(|qc| qc.priority < PRIORITY_MANUAL_FROM_CRATES_IO) .count() - > self.config.public_alert_threshold + > self.config.length_warning_threshold } /// fetch the current queue alerts - pub async fn gather_alerts(&self) -> Result> { + pub async fn gather_alerts(&self) -> Result> { let queue_pending_count = self .pending_count_by_priority() .await @@ -286,10 +287,18 @@ impl AsyncBuildQueue { let mut alerts = Vec::with_capacity(1); - if queue_pending_count > self.config.public_alert_threshold { - alerts.push(GlobalAlert { - url: "/releases/queue".into(), + if queue_pending_count > self.config.length_warning_threshold { + alerts.push(Abnormality { + anchor_id: AnchorId::QueueLength, + url: EscapedURI::from_path("/releases/queue"), text: "long build queue".into(), + explanation: Some( + format!( + "The build queue currently contains more than {} crates, so it might take a while before new published crates get documented.", + self.config.length_warning_threshold, + ) + ), + start_time: None, severity: AlertSeverity::Warn, }); } @@ -620,9 +629,9 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn test_public_alert_threshold_boundary() -> Result<()> { + async fn test_length_warning_threshold_boundary() -> Result<()> { let mut config = Config::from_environment()?; - config.public_alert_threshold = 1; + config.length_warning_threshold = 1; let env = test_queue_with_config(config).await?; let queue = env.queue; @@ -636,9 +645,12 @@ mod tests { assert!(queue.build_queue_is_too_long(queue.queued_crates().await?.iter())); assert_eq!( queue.gather_alerts().await?, - vec![GlobalAlert { - url: "/releases/queue".into(), + vec![Abnormality { + anchor_id: AnchorId::QueueLength, + url: EscapedURI::from_path("/releases/queue"), text: "long build queue".into(), + explanation: Some("The build queue currently contains more than 1 crates, so it might take a while before new published crates get documented.".into()), + start_time: None, severity: AlertSeverity::Warn, }] ); @@ -649,7 +661,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_public_alert_ignores_manual_crates() -> Result<()> { let mut config = Config::from_environment()?; - config.public_alert_threshold = 0; + config.length_warning_threshold = 0; let env = test_queue_with_config(config).await?; let queue = env.queue; diff --git a/crates/lib/docs_rs_database/Cargo.toml b/crates/lib/docs_rs_database/Cargo.toml index 3076eeab0..2193806dd 100644 --- a/crates/lib/docs_rs_database/Cargo.toml +++ b/crates/lib/docs_rs_database/Cargo.toml @@ -17,6 +17,7 @@ docs_rs_env_vars = { path = "../docs_rs_env_vars" } docs_rs_opentelemetry = { path = "../docs_rs_opentelemetry" } docs_rs_registry_api = { path = "../docs_rs_registry_api" } docs_rs_types = { path = "../docs_rs_types" } +docs_rs_uri = { path = "../docs_rs_uri" } docs_rs_utils = { path = "../docs_rs_utils" } futures-util = { workspace = true } hex = "0.4.3" diff --git a/crates/lib/docs_rs_database/src/service_config/abnormalities.rs b/crates/lib/docs_rs_database/src/service_config/abnormalities.rs new file mode 100644 index 000000000..8a116e220 --- /dev/null +++ b/crates/lib/docs_rs_database/src/service_config/abnormalities.rs @@ -0,0 +1,100 @@ +use anyhow::{Result, bail}; +use chrono::{DateTime, Utc}; +use docs_rs_uri::EscapedURI; +use serde::{Deserialize, Serialize}; +use std::{fmt, str::FromStr}; +use strum::VariantArray; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AnchorId { + Manual, + QueueLength, +} + +impl AnchorId { + pub fn as_str(&self) -> &'static str { + match self { + Self::Manual => "manual", + Self::QueueLength => "queue-length", + } + } +} + +impl fmt::Display for AnchorId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl AsRef for AnchorId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +/// alert severity with icon. +/// Used by abnormalities & global alerts +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, VariantArray)] +pub enum AlertSeverity { + #[default] + Warn, + Error, +} + +impl fmt::Display for AlertSeverity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Warn => f.write_str("warn"), + Self::Error => f.write_str("error"), + } + } +} + +impl FromStr for AlertSeverity { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + if s.eq_ignore_ascii_case("warn") { + Ok(Self::Warn) + } else if s.eq_ignore_ascii_case("error") { + Ok(Self::Error) + } else { + bail!("invalid severity: {s}") + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Abnormality { + pub anchor_id: AnchorId, + pub url: EscapedURI, + pub text: String, + /// explanation to be shown on the status page, can be HTML + #[serde(default)] + pub explanation: Option, + #[serde(default)] + pub start_time: Option>, + #[serde(default)] + pub severity: AlertSeverity, +} + +impl Abnormality { + pub fn topbar_url(&self) -> EscapedURI { + EscapedURI::from_path("/-/status/").with_fragment(self.anchor_id.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_is_from_str_for_all_variants() { + for severity in AlertSeverity::VARIANTS { + assert_eq!( + *severity, + severity.to_string().parse::().unwrap() + ); + } + } +} diff --git a/crates/lib/docs_rs_database/src/service_config/global_alert.rs b/crates/lib/docs_rs_database/src/service_config/global_alert.rs deleted file mode 100644 index e5d46931b..000000000 --- a/crates/lib/docs_rs_database/src/service_config/global_alert.rs +++ /dev/null @@ -1,61 +0,0 @@ -use anyhow::{Result, bail}; -use serde::{Deserialize, Serialize}; -use std::{fmt, str::FromStr}; -use strum::VariantArray; - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, VariantArray)] -pub enum AlertSeverity { - #[default] - Warn, - Error, -} - -impl fmt::Display for AlertSeverity { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}", - match self { - Self::Warn => "warn", - Self::Error => "error", - } - ) - } -} - -impl FromStr for AlertSeverity { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - if s.eq_ignore_ascii_case("warn") { - Ok(Self::Warn) - } else if s.eq_ignore_ascii_case("error") { - Ok(Self::Error) - } else { - bail!("invalid severity: {s}") - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GlobalAlert { - pub url: String, - pub text: String, - #[serde(default)] - pub severity: AlertSeverity, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_display_is_from_str_for_all_variants() { - for severity in AlertSeverity::VARIANTS { - assert_eq!( - *severity, - severity.to_string().parse::().unwrap() - ); - } - } -} diff --git a/crates/lib/docs_rs_database/src/service_config/mod.rs b/crates/lib/docs_rs_database/src/service_config/mod.rs index c8931e8e4..d0cd5c98e 100644 --- a/crates/lib/docs_rs_database/src/service_config/mod.rs +++ b/crates/lib/docs_rs_database/src/service_config/mod.rs @@ -1,9 +1,9 @@ -mod global_alert; +mod abnormalities; use anyhow::Result; use serde::{Serialize, de::DeserializeOwned}; -pub use global_alert::{AlertSeverity, GlobalAlert}; +pub use abnormalities::{Abnormality, AlertSeverity, AnchorId}; #[derive(strum::IntoStaticStr)] #[strum(serialize_all = "snake_case")] @@ -12,7 +12,7 @@ pub enum ConfigName { LastSeenIndexReference, QueueLocked, Toolchain, - GlobalAlert, + Abnormality, } pub async fn set_config( @@ -69,7 +69,7 @@ mod tests { #[test_case(ConfigName::RustcVersion, "rustc_version")] #[test_case(ConfigName::QueueLocked, "queue_locked")] #[test_case(ConfigName::LastSeenIndexReference, "last_seen_index_reference")] - #[test_case(ConfigName::GlobalAlert, "global_alert")] + #[test_case(ConfigName::Abnormality, "abnormality")] fn test_configname_variants(variant: ConfigName, expected: &'static str) { let name: &'static str = variant.into(); assert_eq!(name, expected);