diff --git a/CLAUDE.md b/CLAUDE.md index 2a39bfe652..096f935fe2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,8 @@ Test a single package: cargo nextest run -p my_package ``` +`bencher_schema` tests require the `plus` feature (`cargo nextest run -p bencher_schema --features plus`); some `#[cfg(test)]` modules (e.g., `model/project/metric.rs`) use `plus`-only items without a feature gate, so the default-feature test build fails to compile. + `nextest` does not support doctests, so also run: ```bash @@ -184,6 +186,11 @@ Rust is the single source of truth for types: 3. `bencher_valid` is compiled to WASM for browser-side validation 4. `bencher_client` is auto-generated from the OpenAPI spec via progenitor +If `cargo gen-types` fails with `Failed to read input: .ts` and `No such file or directory`, +look for a dangling symlink in the repo (for example `lib/bencher_client/codegen.rs`, +a gitignored convenience symlink into `target/` that dangles after the build dir is cleaned). +Typeshare walks the whole repo and errors on dangling symlinks; delete the symlink. + ## Docker When adding a new crate, update all four `Dockerfile`s: diff --git a/lib/bencher_json/src/project/plot.rs b/lib/bencher_json/src/project/plot.rs index 86bdf51ac7..f5f5a5ac61 100644 --- a/lib/bencher_json/src/project/plot.rs +++ b/lib/bencher_json/src/project/plot.rs @@ -36,6 +36,10 @@ pub struct JsonNewPlot { pub upper_boundary: bool, /// The x-axis to use for the plot. pub x_axis: XAxis, + /// The y-axis scale to use for the plot. + /// Defaults to `auto` when omitted. + #[serde(default)] + pub y_axis: YAxis, /// The window of time for the plot, in seconds. /// Metrics outside of this window will be omitted. pub window: Window, @@ -75,6 +79,7 @@ pub struct JsonPlot { pub lower_boundary: bool, pub upper_boundary: bool, pub x_axis: XAxis, + pub y_axis: YAxis, pub window: Window, pub branches: Vec, pub testbeds: Vec, @@ -112,6 +117,8 @@ pub struct JsonPlotPatch { pub upper_boundary: Option, /// The x-axis to use for the plot. pub x_axis: Option, + /// The y-axis scale to use for the plot. + pub y_axis: Option, /// The window of time for the plot, in seconds. /// Metrics outside of this window will be omitted. pub window: Option, @@ -143,6 +150,7 @@ pub struct JsonPlotPatchNull { pub lower_boundary: Option, pub upper_boundary: Option, pub x_axis: Option, + pub y_axis: Option, pub window: Option, pub branches: Option>, pub testbeds: Option>, @@ -163,6 +171,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { const LOWER_BOUNDARY_FIELD: &str = "lower_boundary"; const UPPER_BOUNDARY_FIELD: &str = "upper_boundary"; const X_AXIS_FIELD: &str = "x_axis"; + const Y_AXIS_FIELD: &str = "y_axis"; const WINDOW_FIELD: &str = "window"; const BRANCHES_FIELD: &str = "branches"; const TESTBEDS_FIELD: &str = "testbeds"; @@ -176,6 +185,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { LOWER_BOUNDARY_FIELD, UPPER_BOUNDARY_FIELD, X_AXIS_FIELD, + Y_AXIS_FIELD, WINDOW_FIELD, BRANCHES_FIELD, TESTBEDS_FIELD, @@ -193,6 +203,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { LowerBoundary, UpperBoundary, XAxis, + YAxis, Window, Branches, Testbeds, @@ -221,6 +232,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { let mut lower_boundary = None; let mut upper_boundary = None; let mut x_axis = None; + let mut y_axis = None; let mut window = None; let mut branches = None; let mut testbeds = None; @@ -271,6 +283,12 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { } x_axis = Some(map.next_value()?); }, + Field::YAxis => { + if y_axis.is_some() { + return Err(de::Error::duplicate_field(Y_AXIS_FIELD)); + } + y_axis = Some(map.next_value()?); + }, Field::Window => { if window.is_some() { return Err(de::Error::duplicate_field(WINDOW_FIELD)); @@ -313,6 +331,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -327,6 +346,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -341,6 +361,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -365,6 +386,7 @@ pub enum PlotKey { LowerBoundary, UpperBoundary, XAxis, + YAxis, } pub const LOWER_VALUE: &str = "lower_value"; @@ -372,12 +394,15 @@ pub const UPPER_VALUE: &str = "upper_value"; pub const LOWER_BOUNDARY: &str = "lower_boundary"; pub const UPPER_BOUNDARY: &str = "upper_boundary"; pub const X_AXIS: &str = "x_axis"; +pub const Y_AXIS: &str = "y_axis"; const DATE_TIME_INT: i32 = 0; const VERSION_INT: i32 = 1; #[typeshare::typeshare] -#[derive(Debug, Clone, Copy, Default, derive_more::Display, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, derive_more::Display, Serialize, Deserialize, +)] #[cfg_attr(feature = "schema", derive(JsonSchema))] #[cfg_attr(feature = "db", derive(diesel::FromSqlRow, diesel::AsExpression))] #[cfg_attr(feature = "db", diesel(sql_type = diesel::sql_types::Integer))] @@ -430,9 +455,107 @@ mod plot_x_axis { } } +const AUTO_INT: i32 = 0; +const LINEAR_INT: i32 = 1; +const LOG_INT: i32 = 2; + +#[typeshare::typeshare] +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, derive_more::Display, Serialize, Deserialize, +)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[cfg_attr(feature = "db", derive(diesel::FromSqlRow, diesel::AsExpression))] +#[cfg_attr(feature = "db", diesel(sql_type = diesel::sql_types::Integer))] +#[serde(rename_all = "snake_case")] +#[repr(i32)] +pub enum YAxis { + /// Automatically adapt the y-axis scale to the data spread. + #[default] + Auto = AUTO_INT, + /// A linear y-axis scale that shows true magnitudes. + Linear = LINEAR_INT, + /// A logarithmic y-axis scale. + Log = LOG_INT, +} + +#[cfg(feature = "db")] +mod plot_y_axis { + use super::{AUTO_INT, LINEAR_INT, LOG_INT, YAxis}; + + #[derive(Debug, thiserror::Error)] + pub enum YAxisError { + #[error("Invalid plot y-axis value: {0}")] + Invalid(i32), + } + + impl diesel::serialize::ToSql for YAxis + where + DB: diesel::backend::Backend, + i32: diesel::serialize::ToSql, + { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, DB>, + ) -> diesel::serialize::Result { + match self { + Self::Auto => AUTO_INT.to_sql(out), + Self::Linear => LINEAR_INT.to_sql(out), + Self::Log => LOG_INT.to_sql(out), + } + } + } + + impl diesel::deserialize::FromSql for YAxis + where + DB: diesel::backend::Backend, + i32: diesel::deserialize::FromSql, + { + fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result { + match i32::from_sql(bytes)? { + AUTO_INT => Ok(Self::Auto), + LINEAR_INT => Ok(Self::Linear), + LOG_INT => Ok(Self::Log), + value => Err(Box::new(YAxisError::Invalid(value))), + } + } + } +} + #[cfg(all(test, any(feature = "server", feature = "client")))] mod tests { - use super::{JsonUpdatePlot, XAxis}; + use super::{JsonUpdatePlot, XAxis, YAxis}; + + #[test] + fn y_axis_serde_round_trip() { + for (y_axis, expected) in [ + (YAxis::Auto, "\"auto\""), + (YAxis::Linear, "\"linear\""), + (YAxis::Log, "\"log\""), + ] { + let json = serde_json::to_string(&y_axis).unwrap(); + assert_eq!(json, expected); + let parsed: YAxis = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, y_axis); + } + } + + #[test] + fn y_axis_default_is_auto() { + assert_eq!(YAxis::default(), YAxis::Auto); + } + + #[test] + fn x_axis_serde_round_trip() { + for (x_axis, expected) in [ + (XAxis::DateTime, "\"date_time\""), + (XAxis::Version, "\"version\""), + ] { + let json = serde_json::to_string(&x_axis).unwrap(); + assert_eq!(json, expected); + let parsed: XAxis = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, x_axis); + } + } #[test] fn deserialize_empty_is_patch_with_all_none() { @@ -447,6 +570,7 @@ mod tests { assert!(patch.lower_boundary.is_none()); assert!(patch.upper_boundary.is_none()); assert!(patch.x_axis.is_none()); + assert!(patch.y_axis.is_none()); assert!(patch.window.is_none()); assert!(patch.branches.is_none()); assert!(patch.testbeds.is_none()); @@ -474,14 +598,15 @@ mod tests { } #[test] - fn deserialize_flags_and_x_axis() { + fn deserialize_flags_and_axes() { let update: JsonUpdatePlot = serde_json::from_str( r#"{ "lower_value": true, "upper_value": false, "lower_boundary": true, "upper_boundary": false, - "x_axis": "version" + "x_axis": "version", + "y_axis": "log" }"#, ) .unwrap(); @@ -493,6 +618,7 @@ mod tests { assert_eq!(patch.lower_boundary, Some(true)); assert_eq!(patch.upper_boundary, Some(false)); assert!(matches!(patch.x_axis, Some(XAxis::Version))); + assert_eq!(patch.y_axis, Some(YAxis::Log)); } #[test] diff --git a/lib/bencher_schema/migrations/2026-07-18-120000_plot_y_axis/down.sql b/lib/bencher_schema/migrations/2026-07-18-120000_plot_y_axis/down.sql new file mode 100644 index 0000000000..7d86817eb2 --- /dev/null +++ b/lib/bencher_schema/migrations/2026-07-18-120000_plot_y_axis/down.sql @@ -0,0 +1,51 @@ +PRAGMA foreign_keys = off; +CREATE TABLE down_plot ( + id INTEGER PRIMARY KEY NOT NULL, + uuid TEXT NOT NULL UNIQUE, + project_id INTEGER NOT NULL, + rank BIGINT NOT NULL, + title TEXT, + lower_value BOOLEAN NOT NULL, + upper_value BOOLEAN NOT NULL, + lower_boundary BOOLEAN NOT NULL, + upper_boundary BOOLEAN NOT NULL, + x_axis INTEGER NOT NULL, + window BIGINT NOT NULL, + created BIGINT NOT NULL, + modified BIGINT NOT NULL, + FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE +); +INSERT INTO down_plot( + id, + uuid, + project_id, + rank, + title, + lower_value, + upper_value, + lower_boundary, + upper_boundary, + x_axis, + window, + created, + modified + ) +SELECT id, + uuid, + project_id, + rank, + title, + lower_value, + upper_value, + lower_boundary, + upper_boundary, + x_axis, + window, + created, + modified +FROM plot; +DROP TABLE plot; +ALTER TABLE down_plot + RENAME TO plot; +CREATE INDEX index_plot_project_created ON plot(project_id, created); +PRAGMA foreign_keys = on; diff --git a/lib/bencher_schema/migrations/2026-07-18-120000_plot_y_axis/up.sql b/lib/bencher_schema/migrations/2026-07-18-120000_plot_y_axis/up.sql new file mode 100644 index 0000000000..4b4c37a7d4 --- /dev/null +++ b/lib/bencher_schema/migrations/2026-07-18-120000_plot_y_axis/up.sql @@ -0,0 +1,54 @@ +PRAGMA foreign_keys = off; +CREATE TABLE up_plot ( + id INTEGER PRIMARY KEY NOT NULL, + uuid TEXT NOT NULL UNIQUE, + project_id INTEGER NOT NULL, + rank BIGINT NOT NULL, + title TEXT, + lower_value BOOLEAN NOT NULL, + upper_value BOOLEAN NOT NULL, + lower_boundary BOOLEAN NOT NULL, + upper_boundary BOOLEAN NOT NULL, + x_axis INTEGER NOT NULL, + y_axis INTEGER NOT NULL, + window BIGINT NOT NULL, + created BIGINT NOT NULL, + modified BIGINT NOT NULL, + FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE +); +INSERT INTO up_plot( + id, + uuid, + project_id, + rank, + title, + lower_value, + upper_value, + lower_boundary, + upper_boundary, + x_axis, + y_axis, + window, + created, + modified + ) +SELECT id, + uuid, + project_id, + rank, + title, + lower_value, + upper_value, + lower_boundary, + upper_boundary, + x_axis, + 0, + window, + created, + modified +FROM plot; +DROP TABLE plot; +ALTER TABLE up_plot + RENAME TO plot; +CREATE INDEX index_plot_project_created ON plot(project_id, created); +PRAGMA foreign_keys = on; diff --git a/lib/bencher_schema/src/model/project/plot/mod.rs b/lib/bencher_schema/src/model/project/plot/mod.rs index 672590dc43..005eba4de3 100644 --- a/lib/bencher_schema/src/model/project/plot/mod.rs +++ b/lib/bencher_schema/src/model/project/plot/mod.rs @@ -1,7 +1,7 @@ use bencher_json::{ BenchmarkUuid, BranchUuid, DateTime, Index, JsonNewPlot, JsonPlot, MeasureUuid, PlotUuid, ResourceName, TestbedUuid, Window, - project::plot::{JsonPlotPatch, JsonPlotPatchNull, JsonUpdatePlot, XAxis}, + project::plot::{JsonPlotPatch, JsonPlotPatchNull, JsonUpdatePlot, XAxis, YAxis}, }; use bencher_rank::{Rank, RankGenerator, Ranked}; use diesel::{BelongingToDsl as _, ExpressionMethods as _, QueryDsl as _, RunQueryDsl as _}; @@ -88,6 +88,7 @@ pub struct QueryPlot { pub lower_boundary: bool, pub upper_boundary: bool, pub x_axis: XAxis, + pub y_axis: YAxis, pub window: Window, pub created: DateTime, pub modified: DateTime, @@ -155,6 +156,7 @@ impl QueryPlot { lower_boundary: None, upper_boundary: None, x_axis: None, + y_axis: None, window: None, modified: now, }; @@ -212,6 +214,7 @@ impl QueryPlot { lower_boundary: None, upper_boundary: None, x_axis: None, + y_axis: None, window: None, modified, }; @@ -251,6 +254,7 @@ impl QueryPlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, created, modified, @@ -265,6 +269,7 @@ impl QueryPlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -289,6 +294,7 @@ impl QueryPlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -358,6 +364,7 @@ impl QueryPlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, modified, }; @@ -443,6 +450,7 @@ pub struct InsertPlot { pub lower_boundary: bool, pub upper_boundary: bool, pub x_axis: XAxis, + pub y_axis: YAxis, pub window: Window, pub created: DateTime, pub modified: DateTime, @@ -465,6 +473,7 @@ impl InsertPlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -501,6 +510,7 @@ impl InsertPlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, created: timestamp, modified: timestamp, @@ -539,6 +549,7 @@ pub struct UpdatePlot { pub lower_boundary: Option, pub upper_boundary: Option, pub x_axis: Option, + pub y_axis: Option, pub window: Option, pub modified: DateTime, } @@ -557,6 +568,7 @@ struct UpdatePlotFields { lower_boundary: Option, upper_boundary: Option, x_axis: Option, + y_axis: Option, window: Option, branches: Option>, testbeds: Option>, @@ -576,6 +588,7 @@ impl From for UpdatePlotFields { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -590,6 +603,7 @@ impl From for UpdatePlotFields { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -606,6 +620,7 @@ impl From for UpdatePlotFields { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -620,6 +635,7 @@ impl From for UpdatePlotFields { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -635,7 +651,10 @@ impl From for UpdatePlotFields { mod tests { use diesel::{ExpressionMethods as _, QueryDsl as _, RunQueryDsl as _, SelectableHelper as _}; - use bencher_json::{DateTime, project::plot::XAxis}; + use bencher_json::{ + DateTime, + project::plot::{XAxis, YAxis}, + }; use super::{ InsertPlot, PlotId, QueryPlot, UpdatePlot, benchmark::InsertPlotBenchmark, @@ -932,6 +951,7 @@ mod tests { lower_boundary: false, upper_boundary: false, x_axis: XAxis::DateTime, + y_axis: YAxis::Auto, window: bencher_json::Window::try_from(2_592_000u32).unwrap(), created: timestamp, modified: timestamp, @@ -1107,6 +1127,7 @@ mod tests { lower_boundary: None, upper_boundary: None, x_axis: None, + y_axis: None, window: None, modified: DateTime::TEST, } @@ -1160,13 +1181,13 @@ mod tests { } #[test] - fn apply_update_scalar_flags_and_x_axis() { + fn apply_update_scalar_flags_and_axes() { let mut conn = setup_test_db(); let base = create_base_entities(&mut conn); let (plot_id, branch, testbed, benchmark, measure) = seed_plot_with_components(&mut conn, base.project_id); - // Toggle some flags and the x-axis; leave the rest unchanged (None). + // Toggle some flags and both axes; leave the rest unchanged (None). let update_plot = UpdatePlot { rank: None, title: None, @@ -1175,6 +1196,7 @@ mod tests { lower_boundary: Some(true), upper_boundary: None, x_axis: Some(XAxis::Version), + y_axis: Some(YAxis::Log), window: None, modified: DateTime::TEST, }; @@ -1191,6 +1213,7 @@ mod tests { assert!(plot.lower_boundary); // set to true assert!(!plot.upper_boundary); // unchanged (create default false) assert!(matches!(plot.x_axis, XAxis::Version)); + assert_eq!(plot.y_axis, YAxis::Log); // updated from the create default // Components were not provided, so they remain intact. assert_eq!(get_plot_branches(&mut conn, plot_id), vec![branch]); @@ -1198,4 +1221,45 @@ mod tests { assert_eq!(get_plot_benchmarks(&mut conn, plot_id), vec![benchmark]); assert_eq!(get_plot_measures(&mut conn, plot_id), vec![measure]); } + + /// Test that a non-default `y_axis` round-trips through insert and query. + #[test] + fn plot_y_axis_round_trips() { + let mut conn = setup_test_db(); + let base = create_base_entities(&mut conn); + let project = get_query_project(&mut conn, base.project_id); + + let rank = QueryPlot::new_rank(&mut conn, &project, None).expect("Failed to get rank"); + let timestamp = DateTime::TEST; + let insert_plot = InsertPlot { + uuid: bencher_json::PlotUuid::new(), + project_id: base.project_id, + rank, + title: None, + lower_value: true, + upper_value: true, + lower_boundary: false, + upper_boundary: false, + x_axis: XAxis::DateTime, + y_axis: YAxis::Log, + window: bencher_json::Window::try_from(2_592_000u32).unwrap(), + created: timestamp, + modified: timestamp, + }; + diesel::insert_into(schema::plot::table) + .values(&insert_plot) + .execute(&mut conn) + .expect("Failed to insert plot"); + let plot_id: PlotId = diesel::select(last_insert_rowid()) + .get_result(&mut conn) + .expect("Failed to get plot id"); + + let query_plot: QueryPlot = schema::plot::table + .filter(schema::plot::id.eq(plot_id)) + .select(QueryPlot::as_select()) + .first(&mut conn) + .expect("Failed to get plot"); + + assert_eq!(query_plot.y_axis, YAxis::Log); + } } diff --git a/lib/bencher_schema/src/schema.rs b/lib/bencher_schema/src/schema.rs index 75579f5530..5a8e968bc2 100644 --- a/lib/bencher_schema/src/schema.rs +++ b/lib/bencher_schema/src/schema.rs @@ -197,6 +197,7 @@ diesel::table! { lower_boundary -> Bool, upper_boundary -> Bool, x_axis -> Integer, + y_axis -> Integer, window -> BigInt, created -> BigInt, modified -> BigInt, diff --git a/lib/bencher_schema/src/test_util.rs b/lib/bencher_schema/src/test_util.rs index 487ff24eed..4080c3ca51 100644 --- a/lib/bencher_schema/src/test_util.rs +++ b/lib/bencher_schema/src/test_util.rs @@ -11,7 +11,11 @@ use bencher_json::{ DateTime, - project::{alert::AlertStatus, boundary::BoundaryLimit}, + project::{ + alert::AlertStatus, + boundary::BoundaryLimit, + plot::{XAxis, YAxis}, + }, }; use diesel::{ Connection as _, ExpressionMethods as _, QueryDsl as _, RunQueryDsl as _, SqliteConnection, @@ -704,7 +708,8 @@ pub fn create_plot( schema::plot::upper_value.eq(true), schema::plot::lower_boundary.eq(false), schema::plot::upper_boundary.eq(false), - schema::plot::x_axis.eq(0), + schema::plot::x_axis.eq(XAxis::DateTime), + schema::plot::y_axis.eq(YAxis::Auto), schema::plot::window.eq(2_592_000i64), schema::plot::created.eq(DateTime::TEST), schema::plot::modified.eq(DateTime::TEST), diff --git a/services/api/openapi.json b/services/api/openapi.json index 6e8cec0181..899aaf0a22 100644 --- a/services/api/openapi.json +++ b/services/api/openapi.json @@ -13733,6 +13733,15 @@ "$ref": "#/components/schemas/XAxis" } ] + }, + "y_axis": { + "description": "The y-axis scale to use for the plot. Defaults to `auto` when omitted.", + "default": "auto", + "allOf": [ + { + "$ref": "#/components/schemas/YAxis" + } + ] } }, "required": [ @@ -15053,6 +15062,9 @@ }, "x_axis": { "$ref": "#/components/schemas/XAxis" + }, + "y_axis": { + "$ref": "#/components/schemas/YAxis" } }, "required": [ @@ -15069,7 +15081,8 @@ "upper_value", "uuid", "window", - "x_axis" + "x_axis", + "y_axis" ] }, "JsonPlotPatch": { @@ -15162,6 +15175,15 @@ "$ref": "#/components/schemas/XAxis" } ] + }, + "y_axis": { + "nullable": true, + "description": "The y-axis scale to use for the plot.", + "allOf": [ + { + "$ref": "#/components/schemas/YAxis" + } + ] } } }, @@ -15241,6 +15263,14 @@ "$ref": "#/components/schemas/XAxis" } ] + }, + "y_axis": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/YAxis" + } + ] } }, "required": [ @@ -18781,6 +18811,31 @@ "version" ] }, + "YAxis": { + "oneOf": [ + { + "description": "Automatically adapt the y-axis scale to the data spread.", + "type": "string", + "enum": [ + "auto" + ] + }, + { + "description": "A linear y-axis scale that shows true magnitudes.", + "type": "string", + "enum": [ + "linear" + ] + }, + { + "description": "A logarithmic y-axis scale.", + "type": "string", + "enum": [ + "log" + ] + } + ] + }, "JsonDirection": { "type": "string", "enum": [ diff --git a/services/cli/src/bencher/sub/project/plot/create.rs b/services/cli/src/bencher/sub/project/plot/create.rs index 78c42e4c94..f6514830e8 100644 --- a/services/cli/src/bencher/sub/project/plot/create.rs +++ b/services/cli/src/bencher/sub/project/plot/create.rs @@ -1,13 +1,14 @@ use bencher_client::types::JsonNewPlot; use bencher_json::{ BenchmarkUuid, BranchUuid, Index, MeasureUuid, ProjectResourceId, ResourceName, TestbedUuid, - Window, project::plot::XAxis, + Window, + project::plot::{XAxis, YAxis}, }; use crate::{ CliError, bencher::{backend::AuthBackend, sub::SubCmd}, - parser::project::plot::{CliPlotCreate, CliXAxis}, + parser::project::plot::{CliPlotCreate, CliXAxis, CliYAxis}, }; #[derive(Debug, Clone)] @@ -24,6 +25,7 @@ pub struct Create { pub lower_boundary: bool, pub upper_boundary: bool, pub x_axis: XAxis, + pub y_axis: YAxis, pub window: Window, pub branches: Vec, pub testbeds: Vec, @@ -45,6 +47,7 @@ impl TryFrom for Create { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -64,6 +67,11 @@ impl TryFrom for Create { CliXAxis::DateTime => XAxis::DateTime, CliXAxis::Version => XAxis::Version, }, + y_axis: match y_axis { + CliYAxis::Auto => YAxis::Auto, + CliYAxis::Linear => YAxis::Linear, + CliYAxis::Log => YAxis::Log, + }, window, branches, testbeds, @@ -84,6 +92,7 @@ impl From for JsonNewPlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -102,6 +111,11 @@ impl From for JsonNewPlot { XAxis::DateTime => bencher_client::types::XAxis::DateTime, XAxis::Version => bencher_client::types::XAxis::Version, }, + y_axis: match y_axis { + YAxis::Auto => bencher_client::types::YAxis::Auto, + YAxis::Linear => bencher_client::types::YAxis::Linear, + YAxis::Log => bencher_client::types::YAxis::Log, + }, window: window.into(), branches: branches.into_iter().map(Into::into).collect(), testbeds: testbeds.into_iter().map(Into::into).collect(), diff --git a/services/cli/src/bencher/sub/project/plot/update.rs b/services/cli/src/bencher/sub/project/plot/update.rs index 9199bb750b..33762d6aca 100644 --- a/services/cli/src/bencher/sub/project/plot/update.rs +++ b/services/cli/src/bencher/sub/project/plot/update.rs @@ -1,13 +1,14 @@ use bencher_client::types::{JsonPlotPatch, JsonPlotPatchNull, JsonUpdatePlot}; use bencher_json::{ BenchmarkUuid, BranchUuid, Index, MeasureUuid, PlotUuid, ProjectResourceId, ResourceName, - TestbedUuid, Window, project::plot::XAxis, + TestbedUuid, Window, + project::plot::{XAxis, YAxis}, }; use crate::{ CliError, bencher::{backend::AuthBackend, sub::SubCmd}, - parser::project::plot::{CliPlotUpdate, CliXAxis}, + parser::project::plot::{CliPlotUpdate, CliXAxis, CliYAxis}, }; #[derive(Debug, Clone)] @@ -25,6 +26,7 @@ pub struct Update { pub lower_boundary: Option, pub upper_boundary: Option, pub x_axis: Option, + pub y_axis: Option, pub window: Option, pub branches: Option>, pub testbeds: Option>, @@ -47,6 +49,7 @@ impl TryFrom for Update { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -67,6 +70,11 @@ impl TryFrom for Update { CliXAxis::DateTime => XAxis::DateTime, CliXAxis::Version => XAxis::Version, }), + y_axis: y_axis.map(|y_axis| match y_axis { + CliYAxis::Auto => YAxis::Auto, + CliYAxis::Linear => YAxis::Linear, + CliYAxis::Log => YAxis::Log, + }), window, branches, testbeds, @@ -87,6 +95,7 @@ impl From for JsonUpdatePlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -99,6 +108,11 @@ impl From for JsonUpdatePlot { XAxis::DateTime => bencher_client::types::XAxis::DateTime, XAxis::Version => bencher_client::types::XAxis::Version, }); + let y_axis = y_axis.map(|y_axis| match y_axis { + YAxis::Auto => bencher_client::types::YAxis::Auto, + YAxis::Linear => bencher_client::types::YAxis::Linear, + YAxis::Log => bencher_client::types::YAxis::Log, + }); let window = window.map(Into::into); let branches = branches.map(|branches| branches.into_iter().map(Into::into).collect()); let testbeds = testbeds.map(|testbeds| testbeds.into_iter().map(Into::into).collect()); @@ -115,6 +129,7 @@ impl From for JsonUpdatePlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -133,6 +148,7 @@ impl From for JsonUpdatePlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, @@ -149,6 +165,7 @@ impl From for JsonUpdatePlot { lower_boundary, upper_boundary, x_axis, + y_axis, window, branches, testbeds, diff --git a/services/cli/src/parser/project/plot.rs b/services/cli/src/parser/project/plot.rs index 6706077b5c..11a2a656a9 100644 --- a/services/cli/src/parser/project/plot.rs +++ b/services/cli/src/parser/project/plot.rs @@ -93,6 +93,10 @@ pub struct CliPlotCreate { #[clap(long)] pub x_axis: CliXAxis, + /// The y-axis scale to use for the plot. + #[clap(long, default_value = "auto")] + pub y_axis: CliYAxis, + /// The window of time for the plot, in seconds. /// Metrics outside of this window will be omitted. #[clap(long, value_name = "SECONDS")] @@ -130,6 +134,15 @@ pub enum CliXAxis { Version, } +/// Supported Y-Axis scales +#[derive(ValueEnum, Debug, Clone)] +#[clap(rename_all = "snake_case")] +pub enum CliYAxis { + Auto, + Linear, + Log, +} + #[derive(Parser, Debug)] pub struct CliPlotView { /// Project slug or UUID @@ -181,6 +194,10 @@ pub struct CliPlotUpdate { #[clap(long)] pub x_axis: Option, + /// The y-axis scale to use for the plot. + #[clap(long)] + pub y_axis: Option, + /// The window of time for the plot, in seconds. /// Metrics outside of this window will be omitted. #[clap(long, value_name = "SECONDS")] diff --git a/services/console/src/chunks/docs-reference/changelog/en/changelog.mdx b/services/console/src/chunks/docs-reference/changelog/en/changelog.mdx index 737e9a4b1d..bb923e2b38 100644 --- a/services/console/src/chunks/docs-reference/changelog/en/changelog.mdx +++ b/services/console/src/chunks/docs-reference/changelog/en/changelog.mdx @@ -8,6 +8,9 @@ - Add runner update channels via `runner up --update-channel ` (default: `stable`); the `canary` channel tracks a rolling prerelease that is rebuilt whenever the runner changes on a Bencher Cloud deploy, so runner changes are field-tested on Bencher Cloud before a versioned release; `canary` runners report their binary checksum and converge on the published build by content rather than by version - Runner self-update checksum fetch failures no longer drop the WebSocket channel into a reconnect loop; the server now logs the failure, skips the update offer, and retries at the runner's next `ready` poll - **BREAKING CHANGE** The reports list endpoint (`GET /v0/projects/{project}/reports`) now omits the `results` and `alerts` for each report by default, drastically reducing response sizes for projects with large reports; set the new `expand` query param to `true` (`bencher report list --expand`) to restore the previous behavior. A new `counts` field summarizes each report with the number of benchmarks and distinct measures per iteration and the total and active alerts. Note that older CLI versions cannot parse the new default list response, so update the CLI before using `bencher report list`; `bencher run` is unaffected +- Add a Y-Axis scale toggle to perf plots with three modes (Auto, Linear, and Log); the Auto default preserves the existing adaptive scaling, Log falls back to Auto when the data includes zero or negative values, and the selection is saved in the plot URL, restored for pinned plots, and can be set with `bencher plot create --y-axis` or changed with `bencher plot update --y-axis` +- Selecting a pinned plot from the Plots tab in the Perf explorer now applies the plot's saved X-Axis and Y-Axis instead of the defaults +- Thin the Branch Version Number x-axis tick labels to fit the plot width; previously every version rendered a label, which overlapped into an unreadable smear on plots with many versions ## `v0.6.8` - **BREAKING CHANGE** Change the default self-hosted API server port from `61016` to the newly [IANA-registered](https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml?search=6610) `6610`. Deployments relying on the default now serve the API on `6610`. Update clients, reverse proxies, and `--host`/`BENCHER_HOST` references (Docker Compose, devcontainer, and docs are updated to match). To stay on `61016`, set `server.bind_address` to `0.0.0.0:61016` (or run `bencher up --api-port 61016`). diff --git a/services/console/src/components/console/perf/PerfFrame.tsx b/services/console/src/components/console/perf/PerfFrame.tsx index 9ace49ae36..73ea8c61f0 100644 --- a/services/console/src/components/console/perf/PerfFrame.tsx +++ b/services/console/src/components/console/perf/PerfFrame.tsx @@ -12,6 +12,7 @@ import type { JsonPerfQuery, JsonProject, XAxis, + YAxis, } from "../../../types/bencher"; import { httpGet } from "../../../util/http"; import { @@ -46,6 +47,7 @@ export interface Props { end_date: Accessor; key: Accessor; x_axis: Accessor; + y_axis: Accessor; clear: Accessor; lower_value: Accessor; upper_value: Accessor; @@ -61,6 +63,7 @@ export interface Props { handleTab: (tab: PerfTab) => void; handleKey: (key: boolean) => void; handleXAxis: (x_axis: XAxis) => void; + handleYAxis: (y_axis: YAxis) => void; handleClear: (clear: boolean) => void; handleLowerValue: (end: boolean) => void; handleUpperValue: (end: boolean) => void; @@ -159,6 +162,7 @@ const PerfFrame = (props: Props) => { end_date={props.end_date} key={props.key} x_axis={props.x_axis} + y_axis={props.y_axis} clear={props.clear} lower_value={props.lower_value} upper_value={props.upper_value} @@ -174,6 +178,7 @@ const PerfFrame = (props: Props) => { handleTab={props.handleTab} handleKey={props.handleKey} handleXAxis={props.handleXAxis} + handleYAxis={props.handleYAxis} handleClear={props.handleClear} handleLowerValue={props.handleLowerValue} handleUpperValue={props.handleUpperValue} diff --git a/services/console/src/components/console/perf/PerfPanel.tsx b/services/console/src/components/console/perf/PerfPanel.tsx index fde8128c74..6315a0dfe0 100644 --- a/services/console/src/components/console/perf/PerfPanel.tsx +++ b/services/console/src/components/console/perf/PerfPanel.tsx @@ -9,7 +9,7 @@ import { createSignal, } from "solid-js"; import { createStore } from "solid-js/store"; -import { PerfTab, isPerfTab, isXAxis } from "../../../config/types"; +import { PerfTab, isPerfTab, isXAxis, isYAxis } from "../../../config/types"; import { type JsonBenchmark, type JsonBranch, @@ -21,6 +21,7 @@ import { PerfQueryKey, PlotKey, XAxis, + YAxis, } from "../../../types/bencher"; import { authUser } from "../../../util/auth"; import { @@ -91,6 +92,7 @@ const UPPER_VALUE_PARAM = PlotKey.UpperValue; const LOWER_BOUNDARY_PARAM = PlotKey.LowerBoundary; const UPPER_BOUNDARY_PARAM = PlotKey.UpperBoundary; const X_AXIS_PARAM = PlotKey.XAxis; +const Y_AXIS_PARAM = PlotKey.YAxis; // TODO remove in due time const RANGE_PARAM = "range"; const CLEAR_PARAM = "clear"; @@ -136,6 +138,7 @@ export const PERF_PLOT_PARAMS = [ TAB_PARAM, KEY_PARAM, X_AXIS_PARAM, + Y_AXIS_PARAM, CLEAR_PARAM, LOWER_VALUE_PARAM, UPPER_VALUE_PARAM, @@ -163,6 +166,7 @@ export const PERF_PLOT_PIN_PARAMS = [ const DEFAULT_PERF_TAB = PerfTab.REPORTS; const DEFAULT_PERF_KEY = true; const DEFAULT_X_AXIS = XAxis.DateTime; +const DEFAULT_Y_AXIS = YAxis.Auto; const DEFAULT_PERF_CLEAR = false; const DEFAULT_PERF_END_VALUE = false; const DEFAULT_PERF_BOUNDARY = false; @@ -266,6 +270,9 @@ const PerfPanel = (props: Props) => { } else { initParams[RANGE_PARAM] = null; } + if (!isYAxis(searchParams[Y_AXIS_PARAM])) { + initParams[Y_AXIS_PARAM] = null; + } if (!isBoolParam(searchParams[CLEAR_PARAM])) { initParams[CLEAR_PARAM] = null; } @@ -422,6 +429,16 @@ const PerfPanel = (props: Props) => { return DEFAULT_X_AXIS; }); + const y_axis = createMemo(() => { + // This check is required for the initial load + // before the query params have been sanitized + const y = searchParams[Y_AXIS_PARAM]; + if (y && isYAxis(y)) { + return y as YAxis; + } + return DEFAULT_Y_AXIS; + }); + // Ironically, a better name for the `clear` param would actually be `dirty`. // It works as a "dirty" bit to indicate that we shouldn't load the first report again. const clear = createMemo(() => @@ -1171,6 +1188,8 @@ const PerfPanel = (props: Props) => { [UPPER_VALUE_PARAM]: plot?.upper_value, [LOWER_BOUNDARY_PARAM]: plot?.lower_boundary, [UPPER_BOUNDARY_PARAM]: plot?.upper_boundary, + [X_AXIS_PARAM]: plot?.x_axis, + [Y_AXIS_PARAM]: plot?.y_axis, [CLEAR_PARAM]: true, }); }; @@ -1204,6 +1223,12 @@ const PerfPanel = (props: Props) => { } }; + const handleYAxis = (y_axis: YAxis) => { + if (isYAxis(y_axis)) { + setSearchParams({ [Y_AXIS_PARAM]: y_axis }); + } + }; + const handleClear = (clear: boolean) => { if (typeof clear === "boolean") { if (clear) { @@ -1222,6 +1247,7 @@ const PerfPanel = (props: Props) => { [LOWER_BOUNDARY_PARAM]: null, [UPPER_BOUNDARY_PARAM]: null, [X_AXIS_PARAM]: null, + [Y_AXIS_PARAM]: null, [EMBED_LOGO_PARAM]: null, [EMBED_TITLE_PARAM]: null, [EMBED_HEADER_PARAM]: null, @@ -1321,6 +1347,7 @@ const PerfPanel = (props: Props) => { lower_boundary={lower_boundary} upper_boundary={upper_boundary} x_axis={x_axis} + y_axis={y_axis} branches={branches} testbeds={testbeds} benchmarks={benchmarks} @@ -1350,6 +1377,7 @@ const PerfPanel = (props: Props) => { end_date={end_date} key={key} x_axis={x_axis} + y_axis={y_axis} clear={clear} lower_value={lower_value} upper_value={upper_value} @@ -1365,6 +1393,7 @@ const PerfPanel = (props: Props) => { handleTab={handleTab} handleKey={handleKey} handleXAxis={handleXAxis} + handleYAxis={handleYAxis} handleClear={handleClear} handleLowerValue={handleLowerValue} handleUpperValue={handleUpperValue} diff --git a/services/console/src/components/console/perf/header/PerfHeader.tsx b/services/console/src/components/console/perf/header/PerfHeader.tsx index c642bfc6e4..1bdaac0031 100644 --- a/services/console/src/components/console/perf/header/PerfHeader.tsx +++ b/services/console/src/components/console/perf/header/PerfHeader.tsx @@ -15,6 +15,7 @@ import { type JsonProject, Visibility, type XAxis, + type YAxis, } from "../../../../types/bencher"; import { isAllowedProjectManage } from "../../../../util/auth"; import { setPageTitle } from "../../../../util/resource"; @@ -35,6 +36,7 @@ export interface Props { lower_boundary: Accessor; upper_boundary: Accessor; x_axis: Accessor; + y_axis: Accessor; branches: Accessor; testbeds: Accessor; benchmarks: Accessor; @@ -99,6 +101,7 @@ const PerfHeader = (props: Props) => { lower_boundary={props.lower_boundary} upper_boundary={props.upper_boundary} x_axis={props.x_axis} + y_axis={props.y_axis} branches={props.branches} testbeds={props.testbeds} benchmarks={props.benchmarks} @@ -118,6 +121,7 @@ const PerfHeader = (props: Props) => { lower_boundary={props.lower_boundary} upper_boundary={props.upper_boundary} x_axis={props.x_axis} + y_axis={props.y_axis} branches={props.branches} testbeds={props.testbeds} benchmarks={props.benchmarks} diff --git a/services/console/src/components/console/perf/header/PinModal.tsx b/services/console/src/components/console/perf/header/PinModal.tsx index 7a36c00c6d..495567562b 100644 --- a/services/console/src/components/console/perf/header/PinModal.tsx +++ b/services/console/src/components/console/perf/header/PinModal.tsx @@ -13,6 +13,7 @@ import type { JsonPerfQuery, JsonProject, XAxis, + YAxis, } from "../../../../types/bencher"; import { httpPost } from "../../../../util/http"; import { NotifyKind, pageNotify } from "../../../../util/notify"; @@ -32,6 +33,7 @@ export interface Props { lower_boundary: Accessor; upper_boundary: Accessor; x_axis: Accessor; + y_axis: Accessor; branches: Accessor; testbeds: Accessor; benchmarks: Accessor; @@ -94,6 +96,7 @@ const PinModal = (props: Props) => { lower_boundary: props.lower_boundary(), upper_boundary: props.upper_boundary(), x_axis: props.x_axis(), + y_axis: props.y_axis(), window: Number.parseInt(form?.window?.value), branches: props.branches(), testbeds: props.testbeds(), diff --git a/services/console/src/components/console/perf/header/UpdateModal.tsx b/services/console/src/components/console/perf/header/UpdateModal.tsx index 6525138065..50c3c38154 100644 --- a/services/console/src/components/console/perf/header/UpdateModal.tsx +++ b/services/console/src/components/console/perf/header/UpdateModal.tsx @@ -14,6 +14,7 @@ import type { JsonPlot, JsonProject, XAxis, + YAxis, } from "../../../../types/bencher"; import { httpGet, httpPatch } from "../../../../util/http"; import { NotifyKind, pageNotify } from "../../../../util/notify"; @@ -36,6 +37,7 @@ export interface Props { lower_boundary: Accessor; upper_boundary: Accessor; x_axis: Accessor; + y_axis: Accessor; branches: Accessor; testbeds: Accessor; benchmarks: Accessor; @@ -182,6 +184,7 @@ const UpdateModal = (props: Props) => { lower_boundary: props.lower_boundary(), upper_boundary: props.upper_boundary(), x_axis: props.x_axis(), + y_axis: props.y_axis(), branches: props.branches(), testbeds: props.testbeds(), benchmarks: props.benchmarks(), diff --git a/services/console/src/components/console/perf/plot/PerfPlot.tsx b/services/console/src/components/console/perf/plot/PerfPlot.tsx index 401c83b3d5..aee1007bda 100644 --- a/services/console/src/components/console/perf/plot/PerfPlot.tsx +++ b/services/console/src/components/console/perf/plot/PerfPlot.tsx @@ -11,6 +11,7 @@ import type { JsonPerf, JsonProject, XAxis, + YAxis, } from "../../../../types/bencher"; import { type Theme, @@ -43,6 +44,7 @@ export interface Props { end_date: Accessor; key: Accessor; x_axis: Accessor; + y_axis: Accessor; clear: Accessor; lower_value: Accessor; upper_value: Accessor; @@ -58,6 +60,7 @@ export interface Props { handleTab: (tab: PerfTab) => void; handleKey: (key: boolean) => void; handleXAxis: (x_axis: XAxis) => void; + handleYAxis: (y_axis: YAxis) => void; handleClear: (clear: boolean) => void; handleLowerValue: (lower_value: boolean) => void; handleUpperValue: (upper_value: boolean) => void; @@ -89,6 +92,7 @@ const PerfPlot = (props: Props) => { end_date={props.end_date} refresh={props.refresh} x_axis={props.x_axis} + y_axis={props.y_axis} clear={props.clear} lower_value={props.lower_value} upper_value={props.upper_value} @@ -101,6 +105,7 @@ const PerfPlot = (props: Props) => { handleStartTime={props.handleStartTime} handleEndTime={props.handleEndTime} handleXAxis={props.handleXAxis} + handleYAxis={props.handleYAxis} handleClear={props.handleClear} handleLowerValue={props.handleLowerValue} handleUpperValue={props.handleUpperValue} @@ -117,6 +122,7 @@ const PerfPlot = (props: Props) => { plotId={props.plotId} measures={props.measures} x_axis={props.x_axis} + y_axis={props.y_axis} lower_value={props.lower_value} upper_value={props.upper_value} lower_boundary={props.lower_boundary} diff --git a/services/console/src/components/console/perf/plot/Plot.tsx b/services/console/src/components/console/perf/plot/Plot.tsx index 51cac9b071..27ce2c3713 100644 --- a/services/console/src/components/console/perf/plot/Plot.tsx +++ b/services/console/src/components/console/perf/plot/Plot.tsx @@ -7,7 +7,7 @@ import { createResource, } from "solid-js"; import { createStore } from "solid-js/store"; -import type { JsonPerf, XAxis } from "../../../../types/bencher"; +import type { JsonPerf, XAxis, YAxis } from "../../../../types/bencher"; import type { Theme } from "../../../navbar/theme/theme"; import PlotKey from "./key/PlotKey"; import LinePlot from "./line/LinePlot"; @@ -19,6 +19,7 @@ export interface Props { plotId: string | undefined; measures: Accessor; x_axis: Accessor; + y_axis: Accessor; lower_value: Accessor; upper_value: Accessor; lower_boundary: Accessor; @@ -75,6 +76,7 @@ const Plot = (props: Props) => { perfData={props.perfData} measures={props.measures} x_axis={props.x_axis} + y_axis={props.y_axis} lower_value={props.lower_value} upper_value={props.upper_value} lower_boundary={props.lower_boundary} diff --git a/services/console/src/components/console/perf/plot/PlotHeader.tsx b/services/console/src/components/console/perf/plot/PlotHeader.tsx index 8be4ec85d2..75c42cffce 100644 --- a/services/console/src/components/console/perf/plot/PlotHeader.tsx +++ b/services/console/src/components/console/perf/plot/PlotHeader.tsx @@ -17,6 +17,7 @@ import { type JsonMeasure, type JsonProject, XAxis, + YAxis, } from "../../../../types/bencher"; import { httpGet } from "../../../../util/http"; import { BACK_PARAM, encodePath } from "../../../../util/url"; @@ -39,6 +40,7 @@ export interface Props { end_date: Accessor; refresh: () => void; x_axis: Accessor; + y_axis: Accessor; clear: Accessor; lower_value: Accessor; upper_value: Accessor; @@ -51,6 +53,7 @@ export interface Props { handleStartTime: (start_time: string) => void; handleEndTime: (end_time: string) => void; handleXAxis: (x_axis: XAxis) => void; + handleYAxis: (y_axis: YAxis) => void; handleClear: (clear: boolean) => void; handleLowerValue: (lower_value: boolean) => void; handleUpperValue: (upper_value: boolean) => void; @@ -334,6 +337,17 @@ const SharedPlot = (props: Props) => { } }); + const yAxisIcon = createMemo(() => { + switch (props.y_axis()) { + case YAxis.Auto: + return ; + case YAxis.Linear: + return ; + case YAxis.Log: + return ; + } + }); + return ( <> @@ -421,6 +435,46 @@ const SharedPlot = (props: Props) => { {xAxisIcon()} +
+
+
+ Y-Axis +
+
+ +
diff --git a/services/console/src/components/console/perf/plot/line/LinePlot.tsx b/services/console/src/components/console/perf/plot/line/LinePlot.tsx index b9617ac9be..87ec1d3af8 100644 --- a/services/console/src/components/console/perf/plot/line/LinePlot.tsx +++ b/services/console/src/components/console/perf/plot/line/LinePlot.tsx @@ -19,11 +19,14 @@ import { type JsonPerfAlert, type JsonPerfMetrics, XAxis, + type YAxis, } from "../../../../../types/bencher"; import { prettyPrintFloat } from "../../../../../util/convert"; import { scale_factor, scale_units } from "../../../../../util/scale"; import { BACK_PARAM, encodePath } from "../../../../../util/url"; import { Theme } from "../../../../navbar/theme/theme"; +import { type YScale, get_y_scale } from "./scale"; +import { get_x_ticks } from "./ticks"; import { addTooltips } from "./tooltip"; // Source: https://twemoji.twitter.com @@ -40,6 +43,7 @@ export interface Props { perfData: Resource; measures: Accessor; x_axis: Accessor; + y_axis: Accessor; lower_value: Accessor; upper_value: Accessor; lower_boundary: Accessor; @@ -60,6 +64,7 @@ const LinePlot = (props: Props) => { ); const [x_axis, setRange] = createSignal(props.x_axis()); + const [y_axis, setYAxis] = createSignal(props.y_axis()); const [lower_value, setLowerValue] = createSignal(props.lower_value()); const [upper_value, setUpperValue] = createSignal(props.upper_value()); const [lower_boundary, setLowerBoundary] = createSignal( @@ -95,6 +100,9 @@ const LinePlot = (props: Props) => { if (props.x_axis() !== x_axis()) { setRange(props.x_axis()); setIsPlotted(false); + } else if (props.y_axis() !== y_axis()) { + setYAxis(props.y_axis()); + setIsPlotted(false); } else if (props.lower_value() !== lower_value()) { setLowerValue(props.lower_value()); setIsPlotted(false); @@ -137,6 +145,7 @@ const LinePlot = (props: Props) => { Plot.plot({ x: { type: linePlot()?.x_axis?.scale_type, + ticks: linePlot()?.x_axis?.ticks, grid: true, label: `${linePlot()?.x_axis?.label}`, labelOffset: 36, @@ -228,9 +237,27 @@ const line_plot = (props: Props) => { right_measure, right_has_data, scale_props, + props.y_axis(), ); const x_axis = get_x_axis(props.x_axis()); + // The version axis is a point scale, which renders one tick label per + // distinct version, so thin the labels to fit the plot width. + // The time scale thins its own ticks. + const x_ticks = + x_axis.scale_type === "point" + ? get_x_ticks( + active_data + .filter((data) => { + const uuid = data?.result?.measure?.uuid; + return uuid !== undefined && scales?.[uuid] !== undefined; + }) + .flatMap( + (data) => data?.line_data?.map((datum) => datum?.number) ?? [], + ), + props.width(), + ) + : undefined; const axis_marks = []; @@ -257,7 +284,7 @@ const line_plot = (props: Props) => { return { metrics_found, - x_axis, + x_axis: { ...x_axis, ticks: x_ticks?.length ? x_ticks : undefined }, marks, hoverStyles: hover_styles(props.theme()), }; @@ -439,7 +466,7 @@ type Scale = { measure: JsonMeasure; factor: number; units: string; - yScale: d3.ScalePower; + yScale: YScale; }; const scale_data = ( @@ -454,9 +481,22 @@ const scale_data = ( lower_boundary: Accessor; upper_boundary: Accessor; }, + y_axis: YAxis, ): [object[], Scales?] => { - const left_scale = get_scale(data, left_measure, left_has_data, props); - const right_scale = get_scale(data, right_measure, right_has_data, props); + const left_scale = get_scale( + data, + left_measure, + left_has_data, + props, + y_axis, + ); + const right_scale = get_scale( + data, + right_measure, + right_has_data, + props, + y_axis, + ); const scaled_data = scale_data_by_factor(data, left_scale, right_scale); return [ scaled_data, @@ -478,6 +518,7 @@ const get_scale = ( lower_boundary: Accessor; upper_boundary: Accessor; }, + y_axis: YAxis, ): undefined | Scale => { if (!measure || !has_data) { return; @@ -490,18 +531,7 @@ const get_scale = ( const factor = scale_factor(min, raw_units); const units = scale_units(min, raw_units); - // Use pow scaling to allow users to more easily reason on graphs with - // highly differentiated values. If the min is less than 10 times smaller - // than the max, use a linear scale. - // - // See: https://observablehq.com/plot/features/scales#continuous-scales - const relativeDifference = max / min; - const exponent = - relativeDifference < 10 - ? 1 - : Math.max(1 / Math.log10(relativeDifference), 1 / 3); - const domain = [min / factor, max / factor]; - const yScale = d3.scalePow().exponent(exponent).domain(domain).nice(); + const yScale = get_y_scale(y_axis, min / factor, max / factor); return { measure, diff --git a/services/console/src/components/console/perf/plot/line/scale.test.ts b/services/console/src/components/console/perf/plot/line/scale.test.ts new file mode 100644 index 0000000000..b6df131081 --- /dev/null +++ b/services/console/src/components/console/perf/plot/line/scale.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "vitest"; +import { YAxis } from "../../../../../types/bencher"; +import { type YScale, get_y_scale } from "./scale"; + +// A d3 pow scale exposes `exponent()`; a d3 log scale exposes `base()`. +const expectPow = (scale: YScale) => { + if (!("exponent" in scale)) { + throw new Error("Expected a pow scale"); + } + return scale; +}; + +const expectLog = (scale: YScale) => { + if (!("base" in scale)) { + throw new Error("Expected a log scale"); + } + return scale; +}; + +describe("get_y_scale", () => { + test("Linear returns a pow scale with exponent 1", () => { + const scale = expectPow(get_y_scale(YAxis.Linear, 1, 100)); + expect(scale.exponent()).toBe(1); + }); + + test("Linear stays exponent 1 even for a large data spread", () => { + const scale = expectPow(get_y_scale(YAxis.Linear, 1, 1_000_000)); + expect(scale.exponent()).toBe(1); + }); + + test("Log returns a log scale when the minimum is positive", () => { + const scale = expectLog(get_y_scale(YAxis.Log, 1, 1000)); + // The domain is niced to cover the data + const [lower, upper] = scale.domain(); + expect(lower).toBeLessThanOrEqual(1); + expect(upper).toBeGreaterThanOrEqual(1000); + // Equal ratios map to equal distances on a log scale + const delta = scale(10) - scale(1); + expect(scale(100) - scale(10)).toBeCloseTo(delta); + expect(scale(1000) - scale(100)).toBeCloseTo(delta); + }); + + test("Log handles a degenerate domain where min equals max", () => { + // d3 nices a collapsed log domain out to the surrounding powers of ten, + // so a plot where every value is identical still renders sanely. + const scale = expectLog(get_y_scale(YAxis.Log, 5, 5)); + const [lower, upper] = scale.domain(); + expect(lower).toBeLessThan(upper); + expect(lower).toBeLessThanOrEqual(5); + expect(upper).toBeGreaterThanOrEqual(5); + expect(Number.isFinite(scale(5))).toBe(true); + }); + + test("Log falls back to the auto scale when the minimum is zero", () => { + const scale = expectPow(get_y_scale(YAxis.Log, 0, 100)); + // max / min is Infinity, so the auto exponent bottoms out at 1/3 + expect(scale.exponent()).toBeCloseTo(1 / 3); + }); + + test("Log falls back to the auto scale when the minimum is negative", () => { + const scale = expectPow(get_y_scale(YAxis.Log, -5, 100)); + // max / min is negative, which is below the 10x cutoff, so linear + expect(scale.exponent()).toBe(1); + }); + + test("Auto returns a linear scale for a spread under 10x", () => { + const scale = expectPow(get_y_scale(YAxis.Auto, 10, 50)); + expect(scale.exponent()).toBe(1); + }); + + test("Auto adapts the exponent to the data spread at 10x and above", () => { + // 1 / log10(100) = 1/2 + const hundred = expectPow(get_y_scale(YAxis.Auto, 1, 100)); + expect(hundred.exponent()).toBeCloseTo(1 / 2); + // 1 / log10(1000) = 1/3 + const thousand = expectPow(get_y_scale(YAxis.Auto, 1, 1000)); + expect(thousand.exponent()).toBeCloseTo(1 / 3); + // The exponent never drops below 1/3 + const million = expectPow(get_y_scale(YAxis.Auto, 1, 1_000_000)); + expect(million.exponent()).toBeCloseTo(1 / 3); + }); + + test("the niced domain covers the min and max", () => { + for (const y_axis of [YAxis.Auto, YAxis.Linear, YAxis.Log]) { + const scale = get_y_scale(y_axis, 3, 97); + const [lower, upper] = scale.domain(); + expect(lower).toBeLessThanOrEqual(3); + expect(upper).toBeGreaterThanOrEqual(97); + } + }); +}); diff --git a/services/console/src/components/console/perf/plot/line/scale.ts b/services/console/src/components/console/perf/plot/line/scale.ts new file mode 100644 index 0000000000..73a3b9fa2d --- /dev/null +++ b/services/console/src/components/console/perf/plot/line/scale.ts @@ -0,0 +1,45 @@ +import * as d3 from "d3"; +import { YAxis } from "../../../../../types/bencher"; + +export type YScale = + | d3.ScalePower + | d3.ScaleLogarithmic; + +// Build the y-axis scale for the selected mode. +// `min` and `max` are already divided by the unit factor. +export const get_y_scale = ( + y_axis: YAxis, + min: number, + max: number, +): YScale => { + switch (y_axis) { + case YAxis.Linear: + // A true linear scale that shows magnitudes as-is. + return d3.scalePow().exponent(1).domain([min, max]).nice(); + case YAxis.Log: + // A genuine logarithmic scale. The logarithm is undefined for zero + // or negative values, so fall back to the auto scale in that case + // rather than breaking the plot. + if (min > 0) { + return d3.scaleLog().domain([min, max]).nice(); + } + return auto_y_scale(min, max); + default: + // Auto: adapt the power-scale exponent to the data spread. + return auto_y_scale(min, max); + } +}; + +// Use pow scaling to allow users to more easily reason on graphs with highly +// differentiated values. If the min is less than 10 times smaller than the max, +// use a linear scale. +// +// See: https://observablehq.com/plot/features/scales#continuous-scales +const auto_y_scale = (min: number, max: number) => { + const relativeDifference = max / min; + const exponent = + relativeDifference < 10 + ? 1 + : Math.max(1 / Math.log10(relativeDifference), 1 / 3); + return d3.scalePow().exponent(exponent).domain([min, max]).nice(); +}; diff --git a/services/console/src/components/console/perf/plot/line/ticks.test.ts b/services/console/src/components/console/perf/plot/line/ticks.test.ts new file mode 100644 index 0000000000..78cf63126f --- /dev/null +++ b/services/console/src/components/console/perf/plot/line/ticks.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; +import { get_x_ticks } from "./ticks"; + +describe("get_x_ticks", () => { + test("returns all distinct values sorted when they fit the width", () => { + expect(get_x_ticks([3, 1, 2], 800)).toEqual([1, 2, 3]); + }); + + test("dedupes version numbers shared across branches", () => { + expect(get_x_ticks([1, 1, 2, 2, 3, 3], 800)).toEqual([1, 2, 3]); + }); + + test("sorts numerically, not lexicographically", () => { + expect(get_x_ticks([100, 20, 3], 800)).toEqual([3, 20, 100]); + }); + + test("thins to roughly one tick per 80px of width", () => { + const versions = Array.from({ length: 100 }, (_, i) => i + 1); + const ticks = get_x_ticks(versions, 800); + expect(ticks).toEqual([1, 11, 21, 31, 41, 51, 61, 71, 81, 91]); + }); + + test("every tick is a member of the input values", () => { + // A point scale is only defined on its domain, + // so every tick must be an actual domain value. + const versions = Array.from({ length: 257 }, (_, i) => i * 3 + 7); + const ticks = get_x_ticks(versions, 1170); + expect(ticks.length).toBeGreaterThan(2); + const domain = new Set(versions); + for (const tick of ticks) { + expect(domain.has(tick)).toBe(true); + } + }); + + test("keeps at least two ticks on narrow plots", () => { + const versions = Array.from({ length: 50 }, (_, i) => i + 1); + expect(get_x_ticks(versions, 100)).toEqual([1, 26]); + }); + + test("ignores missing version numbers", () => { + expect(get_x_ticks([2, undefined, 1, Number.NaN], 800)).toEqual([1, 2]); + }); + + test("returns an empty array for no data", () => { + expect(get_x_ticks([], 800)).toEqual([]); + }); +}); diff --git a/services/console/src/components/console/perf/plot/line/ticks.ts b/services/console/src/components/console/perf/plot/line/ticks.ts new file mode 100644 index 0000000000..8618e6d0c9 --- /dev/null +++ b/services/console/src/components/console/perf/plot/line/ticks.ts @@ -0,0 +1,27 @@ +// One tick label per ~80px of plot width, matching the default `tickSpacing` +// that Observable Plot uses to thin the ticks on continuous x-axis scales. +const TICK_SPACING = 80; + +// Explicit tick values for the version number x-axis. +// The version axis is a point scale, and Observable Plot renders one tick per +// domain value on a point scale, so the labels overlap once there are more +// versions than fit the width. Thin them to an even stride instead. +export const get_x_ticks = ( + versions: (number | undefined)[], + width: number, +): number[] => { + const distinct = [ + ...new Set( + versions.filter( + (version): version is number => + typeof version === "number" && Number.isFinite(version), + ), + ), + ].sort((a, b) => a - b); + const maxTicks = Math.max(2, Math.floor(width / TICK_SPACING)); + if (distinct.length <= maxTicks) { + return distinct; + } + const step = Math.ceil(distinct.length / maxTicks); + return distinct.filter((_, index) => index % step === 0); +}; diff --git a/services/console/src/components/console/plots/PinnedFrame.tsx b/services/console/src/components/console/plots/PinnedFrame.tsx index 573f5ac00c..c054515619 100644 --- a/services/console/src/components/console/plots/PinnedFrame.tsx +++ b/services/console/src/components/console/plots/PinnedFrame.tsx @@ -12,6 +12,7 @@ import { type JsonPerfQuery, type JsonPlot, XAxis, + YAxis, } from "../../../types/bencher"; import { timeToDateOnlyIso } from "../../../util/convert"; import { theme } from "../../navbar/theme/util"; @@ -156,6 +157,7 @@ const PinnedFrame = (props: Props) => { const [key, setKey] = createSignal(false); const x_axis = createMemo(() => props.plot?.x_axis ?? XAxis.DateTime); + const y_axis = createMemo(() => props.plot?.y_axis ?? YAxis.Auto); const clear = createMemo(() => false); const lower_value = createMemo(() => props.plot?.lower_value ?? false); @@ -170,7 +172,9 @@ const PinnedFrame = (props: Props) => { const embed_header = createMemo(() => props.embed?.header === true); const embed_key = createMemo(() => props.embed?.key === true); - const handleVoid = (_void: string | PerfTab | boolean | XAxis | null) => {}; + const handleVoid = ( + _void: string | PerfTab | boolean | XAxis | YAxis | null, + ) => {}; return (
@@ -195,6 +199,7 @@ const PinnedFrame = (props: Props) => { end_date={end_date} key={key} x_axis={x_axis} + y_axis={y_axis} clear={clear} lower_value={lower_value} upper_value={upper_value} @@ -210,6 +215,7 @@ const PinnedFrame = (props: Props) => { handleTab={handleVoid} handleKey={setKey} handleXAxis={handleVoid} + handleYAxis={handleVoid} handleClear={handleVoid} handleLowerValue={handleVoid} handleUpperValue={handleVoid} diff --git a/services/console/src/components/console/plots/util.ts b/services/console/src/components/console/plots/util.ts index 23366328ff..8e11239355 100644 --- a/services/console/src/components/console/plots/util.ts +++ b/services/console/src/components/console/plots/util.ts @@ -23,6 +23,7 @@ export const plotQueryString = (plot: JsonPlot) => { newParams.set(PlotKey.LowerBoundary, plot?.lower_boundary.toString()); newParams.set(PlotKey.UpperBoundary, plot?.upper_boundary.toString()); newParams.set(PlotKey.XAxis, plot?.x_axis); + newParams.set(PlotKey.YAxis, plot?.y_axis); newParams.set(PerfQueryKey.Branches, plot?.branches.toString()); newParams.set(PerfQueryKey.Testbeds, plot?.testbeds.toString()); newParams.set(PerfQueryKey.Benchmarks, plot?.benchmarks.toString()); diff --git a/services/console/src/config/types.test.ts b/services/console/src/config/types.test.ts index 7598efdbeb..f9c3d565a1 100644 --- a/services/console/src/config/types.test.ts +++ b/services/console/src/config/types.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from "vitest"; -import { XAxis } from "../types/bencher"; +import { XAxis, YAxis } from "../types/bencher"; import { BencherResource, PerfTab, isPerfTab, isXAxis, + isYAxis, resourcePlural, resourceSingular, } from "./types"; @@ -104,3 +105,28 @@ describe("isXAxis", () => { expect(isXAxis(undefined)).toBe(false); }); }); + +describe("isYAxis", () => { + test("returns true for Auto", () => { + expect(isYAxis(YAxis.Auto)).toBe(true); + expect(isYAxis("auto")).toBe(true); + }); + + test("returns true for Linear", () => { + expect(isYAxis(YAxis.Linear)).toBe(true); + expect(isYAxis("linear")).toBe(true); + }); + + test("returns true for Log", () => { + expect(isYAxis(YAxis.Log)).toBe(true); + expect(isYAxis("log")).toBe(true); + }); + + test("returns false for invalid string", () => { + expect(isYAxis("invalid")).toBe(false); + }); + + test("returns false for undefined", () => { + expect(isYAxis(undefined)).toBe(false); + }); +}); diff --git a/services/console/src/config/types.tsx b/services/console/src/config/types.tsx index b50d91f33f..22d89b4c64 100644 --- a/services/console/src/config/types.tsx +++ b/services/console/src/config/types.tsx @@ -1,4 +1,4 @@ -import { XAxis } from "../types/bencher"; +import { XAxis, YAxis } from "../types/bencher"; export enum BencherResource { ORGANIZATIONS = "organizations", @@ -211,4 +211,15 @@ export const isXAxis = (xAxis: undefined | string) => { } }; +export const isYAxis = (yAxis: undefined | string) => { + switch (yAxis) { + case YAxis.Auto: + case YAxis.Linear: + case YAxis.Log: + return true; + default: + return false; + } +}; + export const embedHeight = 780; diff --git a/services/console/src/types/bencher.ts b/services/console/src/types/bencher.ts index 354ada5412..1a9b207a73 100644 --- a/services/console/src/types/bencher.ts +++ b/services/console/src/types/bencher.ts @@ -601,6 +601,15 @@ export enum XAxis { Version = "version", } +export enum YAxis { + /** Automatically adapt the y-axis scale to the data spread. */ + Auto = "auto", + /** A linear y-axis scale that shows true magnitudes. */ + Linear = "linear", + /** A logarithmic y-axis scale. */ + Log = "log", +} + export interface JsonNewPlot { /** * The index of the plot. @@ -622,6 +631,11 @@ export interface JsonNewPlot { upper_boundary: boolean; /** The x-axis to use for the plot. */ x_axis: XAxis; + /** + * The y-axis scale to use for the plot. + * Defaults to `auto` when omitted. + */ + y_axis?: YAxis; /** * The window of time for the plot, in seconds. * Metrics outside of this window will be omitted. @@ -985,6 +999,7 @@ export interface JsonPlot { lower_boundary: boolean; upper_boundary: boolean; x_axis: XAxis; + y_axis: YAxis; window: Window; branches: Uuid[]; testbeds: Uuid[]; @@ -1316,6 +1331,7 @@ export enum PlotKey { LowerBoundary = "lower_boundary", UpperBoundary = "upper_boundary", XAxis = "x_axis", + YAxis = "y_axis", } export enum ProjectPermission {