This is an automated email from the ASF dual-hosted git repository. spetz pushed a commit to branch bench_ui_update in repository https://gitbox.apache.org/repos/asf/iggy.git
commit e7bf114d6f6ac0fcfcdc0595a3999f1c7a6b6e56 Author: spetz <[email protected]> AuthorDate: Tue Apr 21 11:08:36 2026 +0200 refactor(bench): unify benchmarks list and fix best-pick selection --- assets/benchmarking_platform.png | Bin 1756918 -> 1165558 bytes core/bench/dashboard/frontend/assets/style.css | 250 +++++++++++-- .../frontend/src/components/app_content.rs | 46 ++- .../dashboard/frontend/src/components/footer.rs | 4 +- .../frontend/src/components/layout/hero.rs | 204 ++++++---- .../frontend/src/components/layout/main_content.rs | 55 ++- .../frontend/src/components/layout/sidebar.rs | 233 +++++++++--- .../frontend/src/components/layout/top_app_bar.rs | 88 +++-- .../{benchmark_selector.rs => benchmarks_list.rs} | 118 +++--- .../src/components/selectors/gitref_selector.rs | 63 ---- .../src/components/selectors/hardware_selector.rs | 61 --- .../frontend/src/components/selectors/mod.rs | 5 +- .../selectors/recent_benchmarks_selector.rs | 302 --------------- .../components/tooltips/benchmark_info_tooltip.rs | 23 +- .../components/tooltips/server_stats_tooltip.rs | 2 - core/bench/dashboard/frontend/src/main.rs | 1 + .../dashboard/frontend/src/state/benchmark.rs | 412 ++++++++++++++++++--- core/bench/dashboard/frontend/src/state/ui.rs | 25 +- core/bench/dashboard/frontend/src/version.rs | 180 +++++++++ core/bench/report/src/plotting/chart.rs | 23 +- 20 files changed, 1295 insertions(+), 800 deletions(-) diff --git a/assets/benchmarking_platform.png b/assets/benchmarking_platform.png index dfc9e8c71..de8d1a74a 100644 Binary files a/assets/benchmarking_platform.png and b/assets/benchmarking_platform.png differ diff --git a/core/bench/dashboard/frontend/assets/style.css b/core/bench/dashboard/frontend/assets/style.css index 2e87bfc80..077bbd14f 100644 --- a/core/bench/dashboard/frontend/assets/style.css +++ b/core/bench/dashboard/frontend/assets/style.css @@ -335,14 +335,15 @@ body { } .chart-title-identifier { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: center; font-size: var(--font-size-md, 0.75rem); color: var(--color-text-muted); - font-style: italic; font-weight: 500; margin-top: var(--spacing-sm); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; max-width: 100%; padding: 0 var(--spacing-sm); box-sizing: border-box; @@ -352,6 +353,35 @@ body.dark .chart-title-identifier { color: var(--color-dark-text-secondary); } +.chart-title-sep { + opacity: 0.5; +} + +.chart-title-gitref { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border-radius: 999px; + background: rgba(255, 145, 3, 0.1); + color: #ff9103; + font-weight: 600; + font-style: normal; + text-decoration: none; + border: 1px solid rgba(255, 145, 3, 0.25); + transition: background 140ms ease, border-color 140ms ease, transform 140ms ease; +} + +.chart-title-gitref:hover { + background: rgba(255, 145, 3, 0.18); + border-color: rgba(255, 145, 3, 0.5); + transform: translateY(-1px); +} + +.chart-title-gitref svg { + opacity: 0.75; +} + .single-view { flex: 1; min-height: 0; @@ -562,6 +592,33 @@ body.dark .app-bar { display: inline-flex; } +.app-bar-toast { + position: absolute; + top: calc(100% + 8px); + right: 0; + padding: 5px 10px; + background: #111827; + color: #fff; + font-size: 11px; + font-weight: 600; + border-radius: 6px; + white-space: nowrap; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); + pointer-events: none; + animation: app-bar-toast-in 160ms ease; + z-index: 40; +} + +body.dark .app-bar-toast { + background: #f5f5f5; + color: #0b1220; +} + +@keyframes app-bar-toast-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + .app-bar-brand { display: inline-flex; align-items: center; @@ -1098,6 +1155,17 @@ body.dark .segment.active { font-size: 11px; } +.footer-meta a { + color: var(--color-text-secondary); + text-decoration: none; + font-weight: 500; + transition: color 150ms; +} + +.footer-meta a:hover { + color: #ff9103; +} + .footer-version { padding: 2px 6px; background: var(--color-border); @@ -1652,44 +1720,50 @@ body.dark .sidebar-search-hint { background: rgba(255, 255, 255, 0.08); } -.sidebar-scope { +.sidebar-facet-row { display: grid; grid-template-columns: 1fr 1fr; - gap: 2px; - padding: 3px; - background: var(--color-border); - border-radius: 8px; + gap: 8px; } -body.dark .sidebar-scope { - background: rgba(255, 255, 255, 0.04); +.sidebar-facet { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; } -.sidebar-scope-btn { - padding: 7px 10px; - background: transparent; - border: none; - border-radius: 6px; +.sidebar-facet-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; color: var(--color-text-secondary); - font-family: inherit; - font-size: 12.5px; - font-weight: 500; - cursor: pointer; - transition: all 150ms; } -.sidebar-scope-btn:hover { +.sidebar-facet-select { + width: 100%; + padding: 6px 8px; + font-size: 12px; + font-family: inherit; + background: var(--color-background); color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 6px; + cursor: pointer; + transition: border-color 150ms; } -.sidebar-scope-btn.active { - background: var(--color-background); - color: var(--color-text); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +.sidebar-facet-select:hover, +.sidebar-facet-select:focus { + border-color: #ff9103; + outline: none; } -body.dark .sidebar-scope-btn.active { - background: var(--color-dark-background); +body.dark .sidebar-facet-select { + background: var(--color-dark-input); + color: var(--color-dark-text); + border-color: var(--color-dark-border); } .sidebar-kind-chips { @@ -2220,6 +2294,82 @@ body.dark .hero-v2 { line-height: 1.5; } +.hero-v2-loading { + justify-content: center; + align-items: center; + min-height: 520px; +} + +.hero-v2-loading-inner { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + text-align: center; + max-width: 520px; + padding: 0 24px; +} + +.hero-v2-loading-mark { + width: 112px; + height: 112px; + object-fit: contain; + filter: drop-shadow(0 0 24px rgba(255, 145, 3, 0.35)); + animation: hero-loading-pulse 1800ms ease-in-out infinite; +} + +.hero-v2-loading-brand { + font-family: var(--hero-mono); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + color: #ff9103; + margin-top: 4px; +} + +.hero-v2-loading-sub { + font-size: clamp(28px, 4vw, 44px); + font-weight: 800; + letter-spacing: -0.02em; + color: var(--hero-text); + line-height: 1; +} + +.hero-v2-loading-slow { + margin: 8px 0 0; + color: var(--hero-muted); + font-size: 13px; + line-height: 1.5; + animation: hero-fade-in 400ms ease both; +} + +@keyframes hero-loading-pulse { + 0%, 100% { + opacity: 0.4; + transform: scale(0.96); + } + 50% { + opacity: 1; + transform: scale(1); + } +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .hero-v2-headline { display: flex; flex-direction: column; @@ -2271,6 +2421,39 @@ body.dark .hero-v2 { font-family: var(--hero-mono); font-size: 13px; letter-spacing: 0.02em; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 6px; +} + +.hero-v2-sub-sep { + color: var(--hero-muted); + opacity: 0.6; +} + +.hero-v2-sub-gitref { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border-radius: 999px; + background: rgba(255, 145, 3, 0.1); + color: #ff9103; + font-weight: 600; + text-decoration: none; + border: 1px solid rgba(255, 145, 3, 0.25); + transition: background 140ms ease, border-color 140ms ease, transform 140ms ease; +} + +.hero-v2-sub-gitref:hover { + background: rgba(255, 145, 3, 0.18); + border-color: rgba(255, 145, 3, 0.5); + transform: translateY(-1px); +} + +.hero-v2-sub-gitref svg { + opacity: 0.75; } .hero-v2-tagline { @@ -2959,6 +3142,17 @@ body.dark .benchmark-meta-chip { .benchmark-meta-chip.flavor-latency .benchmark-meta-chip-label { color: #ff9103; opacity: 0.85; } .benchmark-meta-chip.flavor-throughput .benchmark-meta-chip-label { color: #38bdf8; opacity: 0.85; } +.compare-pane .benchmark-meta-row { + grid-template-columns: 1fr; + gap: 4px; + align-items: start; +} + +.compare-pane .benchmark-meta-label { + border-right: none; + padding-right: 0; +} + @media (max-width: 720px) { .benchmark-meta-row { grid-template-columns: 1fr; diff --git a/core/bench/dashboard/frontend/src/components/app_content.rs b/core/bench/dashboard/frontend/src/components/app_content.rs index 5ecb741aa..e3dc1a3c6 100644 --- a/core/bench/dashboard/frontend/src/components/app_content.rs +++ b/core/bench/dashboard/frontend/src/components/app_content.rs @@ -218,23 +218,35 @@ pub fn app_content() -> Html { gitref_ctx.state.selected_gitref.clone(), ); - let on_gitref_select = { - let gitref_dispatch = gitref_ctx.dispatch.clone(); - Callback::from(move |gitref: String| { - gitref_dispatch.emit(GitrefAction::SetSelectedGitref(Some(gitref))); - }) - }; - - let on_hardware_select = { - let hardware_dispatch = hardware_ctx.dispatch.clone(); + { + let selected_uuid = benchmark_ctx + .state + .selected_benchmark + .as_ref() + .map(|benchmark| benchmark.uuid.to_string()); + let route_uuid = match route.as_ref() { + Some(AppRoute::Benchmark { uuid }) => Some(uuid.clone()), + _ => None, + }; let navigator = navigator.clone(); - Callback::from(move |hardware_id: String| { - hardware_dispatch.emit(HardwareAction::SelectHardware(Some(hardware_id))); - if let Some(nav) = navigator.as_ref() { - nav.push(&AppRoute::Home); - } - }) - }; + let is_loading_handle = is_loading_from_url.clone(); + use_effect_with( + (selected_uuid, route_uuid), + move |(selected_uuid, route_uuid)| { + if *is_loading_handle { + return; + } + if let (Some(selected), Some(current)) = (selected_uuid, route_uuid) + && selected != current + && let Some(nav) = navigator.as_ref() + { + nav.push(&AppRoute::Benchmark { + uuid: selected.clone(), + }); + } + }, + ); + } let show_detail = matches!( route, @@ -250,7 +262,7 @@ pub fn app_content() -> Html { )}> <TopAppBar show_sidebar_toggle={show_detail} show_detail_actions={show_detail} /> if show_detail { - <Sidebar on_gitref_select={on_gitref_select} on_hardware_select={on_hardware_select} /> + <Sidebar /> <MainContent selected_gitref={gitref_ctx.state.selected_gitref.clone().unwrap_or_default()} /> } else { <Hero selected_gitref={gitref_ctx.state.selected_gitref.clone().unwrap_or_default()} /> diff --git a/core/bench/dashboard/frontend/src/components/footer.rs b/core/bench/dashboard/frontend/src/components/footer.rs index 8bd5d2d95..58cfd6f0f 100644 --- a/core/bench/dashboard/frontend/src/components/footer.rs +++ b/core/bench/dashboard/frontend/src/components/footer.rs @@ -41,7 +41,9 @@ pub fn footer() -> Html { <div class="footer-meta"> <span class="footer-version">{"v"}{env!("CARGO_PKG_VERSION")}</span> <span class="footer-sep">{"•"}</span> - <span>{"Apache Software Foundation"}</span> + <a href="https://www.apache.org/" target="_blank" rel="noopener noreferrer"> + {"Apache Software Foundation"} + </a> </div> </div> </footer> diff --git a/core/bench/dashboard/frontend/src/components/layout/hero.rs b/core/bench/dashboard/frontend/src/components/layout/hero.rs index a558f73f3..a5905d87a 100644 --- a/core/bench/dashboard/frontend/src/components/layout/hero.rs +++ b/core/bench/dashboard/frontend/src/components/layout/hero.rs @@ -17,15 +17,14 @@ use crate::api; use crate::components::chart::tail_chart::TailChart; -use crate::format::{format_ms, nan_safe_cmp}; +use crate::format::format_ms; use crate::router::AppRoute; -use crate::state::benchmark::{pick_best_from_recent_batch, use_benchmark}; +use crate::state::benchmark::{latest_sweep, pick_best_from_recent_batch, use_benchmark}; use bench_dashboard_shared::BenchmarkReportLight; -use bench_report::benchmark_kind::BenchmarkKind; use chrono::DateTime; use gloo::console::log; +use gloo::timers::callback::Timeout; use std::cell::Cell; -use std::collections::BTreeMap; use std::rc::Rc; use yew::platform::spawn_local; use yew::prelude::*; @@ -40,10 +39,14 @@ pub struct HeroProps { pub fn hero(props: &HeroProps) -> Html { let benchmark_ctx = use_benchmark(); let navigator = use_navigator(); + let (is_dark, _) = use_context::<(bool, Callback<()>)>().expect("Theme context not found"); let recent = use_state(Vec::<BenchmarkReportLight>::new); + let is_loading = use_state(|| true); + let is_slow = use_state(|| false); { let recent = recent.clone(); + let is_loading = is_loading.clone(); let cancelled = Rc::new(Cell::new(false)); let cancelled_async = cancelled.clone(); use_effect_with((), move |_| { @@ -52,49 +55,46 @@ pub fn hero(props: &HeroProps) -> Html { Ok(data) => { if !cancelled_async.get() { recent.set(data); + is_loading.set(false); + } + } + Err(error) => { + log!(format!("Hero: fetch_recent_benchmarks failed: {}", error)); + if !cancelled_async.get() { + is_loading.set(false); } } - Err(error) => log!(format!("Hero: fetch_recent_benchmarks failed: {}", error)), } }); move || cancelled.set(true) }); } - let recent_vec = (*recent).clone(); - let source: Vec<&BenchmarkReportLight> = if recent_vec.is_empty() { - benchmark_ctx.state.entries.values().flatten().collect() - } else { - recent_vec.iter().collect() - }; - let unrestricted: Vec<&BenchmarkReportLight> = source - .iter() - .copied() - .filter(|benchmark| benchmark.params.rate_limit.is_none()) - .collect(); - let mut stats = compute_stats(unrestricted.iter().copied()); - stats.showcase = unrestricted - .iter() - .copied() - .max_by(|left, right| nan_safe_cmp(throughput_mb(left), throughput_mb(right))) - .cloned(); - if stats.showcase.is_none() && !source.is_empty() { - stats.showcase = pick_best_from_recent_batch(&source); + { + let is_slow = is_slow.clone(); + let is_loading_value = *is_loading; + use_effect_with(is_loading_value, move |loading| { + if !*loading { + is_slow.set(false); + return Box::new(|| ()) as Box<dyn FnOnce()>; + } + let timeout = Timeout::new(2_000, move || is_slow.set(true)); + Box::new(move || drop(timeout)) as Box<dyn FnOnce()> + }); + } + + if *is_loading { + return render_hero_loading(is_dark, *is_slow); } + let recent_vec = (*recent).clone(); + let source: Vec<&BenchmarkReportLight> = recent_vec.iter().collect(); + let sweep = latest_sweep(&source); + let mut stats = compute_stats(sweep.iter().copied()); + stats.showcase = pick_best_from_recent_batch(&source); + if stats.total == 0 { - return html! { - <div class="hero-v2 hero-v2-empty"> - { render_background_grid() } - <div class="hero-v2-empty-inner"> - <div class="hero-v2-eyebrow">{"Apache Iggy"}</div> - <h1 class="hero-v2-empty-title">{"Benchmarks"}</h1> - <p class="hero-v2-empty-sub"> - {"Pick a hardware and gitref in the sidebar to view performance data."} - </p> - </div> - </div> - }; + return render_hero_loading(is_dark, true); } let hardware = benchmark_ctx @@ -102,11 +102,12 @@ pub fn hero(props: &HeroProps) -> Html { .current_hardware .clone() .unwrap_or_default(); - let gitref_suffix = if props.selected_gitref.is_empty() { - String::new() - } else { - format!(" @ {}", props.selected_gitref) - }; + let sweep_gitref = stats + .showcase + .as_ref() + .and_then(|showcase| showcase.params.gitref.clone()) + .filter(|gitref| !gitref.is_empty()) + .or_else(|| Some(props.selected_gitref.clone()).filter(|gitref| !gitref.is_empty())); let on_view_details = stats.showcase.as_ref().map(|showcase| { let uuid = showcase.uuid.to_string(); @@ -119,19 +120,14 @@ pub fn hero(props: &HeroProps) -> Html { }); let on_browse_click = { - let latest_uuid = latest_uuid_from_entries(&benchmark_ctx.state.entries); let navigator = navigator.clone(); Callback::from(move |_: MouseEvent| { - if let Some(uuid) = latest_uuid.clone() { - navigate_to_benchmark(&navigator, uuid); - } else { - let navigator = navigator.clone(); - spawn_local(async move { - if let Some(uuid) = fetch_latest_uuid().await { - navigate_to_benchmark(&navigator, uuid); - } - }); - } + let navigator = navigator.clone(); + spawn_local(async move { + if let Some(uuid) = fetch_latest_uuid().await { + navigate_to_benchmark(&navigator, uuid); + } + }); }) }; @@ -139,7 +135,7 @@ pub fn hero(props: &HeroProps) -> Html { <div class="hero-v2"> { render_background_grid() } <div class="hero-v2-inner"> - { render_headline(&stats, &hardware, &gitref_suffix, &on_browse_click) } + { render_headline(&stats, &hardware, sweep_gitref.as_deref(), &on_browse_click) } { render_stat_cards(&stats) } { match (stats.showcase.as_ref(), on_view_details) { @@ -157,16 +153,6 @@ pub fn hero(props: &HeroProps) -> Html { } } -fn latest_uuid_from_entries( - entries: &BTreeMap<BenchmarkKind, Vec<BenchmarkReportLight>>, -) -> Option<String> { - entries - .values() - .flatten() - .max_by(|left, right| left.timestamp.cmp(&right.timestamp)) - .map(|benchmark| benchmark.uuid.to_string()) -} - async fn fetch_latest_uuid() -> Option<String> { match api::fetch_recent_benchmarks(Some(1)).await { Ok(recent) => recent.into_iter().next().map(|b| b.uuid.to_string()), @@ -273,7 +259,7 @@ fn render_background_grid() -> Html { fn render_headline( stats: &HeroStats, hardware: &str, - gitref_suffix: &str, + gitref: Option<&str>, on_browse_click: &Callback<MouseEvent>, ) -> Html { let (value, unit, subject) = match &stats.peak_mb_s { @@ -283,11 +269,6 @@ fn render_headline( } None => ("-".to_string(), "MB/s", String::new()), }; - let sub = if subject.is_empty() { - format!("{hardware}{gitref_suffix}") - } else { - format!("{subject} · {hardware}{gitref_suffix}") - }; html! { <div class="hero-v2-headline"> @@ -296,7 +277,9 @@ fn render_headline( <span class="hero-v2-big">{value}</span> <span class="hero-v2-unit">{unit}</span> </h1> - <p class="hero-v2-sub">{sub}</p> + <p class="hero-v2-sub"> + { render_hero_sub(&subject, hardware, gitref) } + </p> <p class="hero-v2-tagline"> {"Modern hardware is incredibly capable. "} <span class="hero-v2-tagline-accent">{"Apache Iggy was built for it."}</span> @@ -319,6 +302,50 @@ fn render_headline( } } +fn render_hero_sub(subject: &str, hardware: &str, gitref: Option<&str>) -> Html { + let prefix = match (subject.is_empty(), hardware.is_empty()) { + (true, true) => String::new(), + (true, false) => hardware.to_string(), + (false, true) => subject.to_string(), + (false, false) => format!("{subject} · {hardware}"), + }; + let has_prefix = !prefix.is_empty(); + let gitref = gitref.map(str::to_string); + let gitref_owned = gitref.clone(); + + html! { + <> + if has_prefix { + <span>{prefix}</span> + } + if let Some(gitref) = gitref_owned { + if has_prefix { + <span class="hero-v2-sub-sep">{" @ "}</span> + } + <a + class="hero-v2-sub-gitref" + href={iggy_gitref_url(&gitref)} + target="_blank" + rel="noopener noreferrer" + title={format!("Browse apache/iggy at {gitref}")} + > + {gitref} + <svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" + fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> + <polyline points="15 3 21 3 21 9" /> + <line x1="10" y1="14" x2="21" y2="3" /> + </svg> + </a> + } + </> + } +} + +fn iggy_gitref_url(gitref: &str) -> String { + format!("https://github.com/apache/iggy/tree/{gitref}") +} + fn render_stat_cards(stats: &HeroStats) -> Html { html! { <div class="hero-v2-cards"> @@ -404,14 +431,6 @@ fn render_showcase_card(stagger: usize, showcase: Option<&BenchmarkReportLight>) } } -fn throughput_mb(benchmark: &BenchmarkReportLight) -> f64 { - benchmark - .group_metrics - .first() - .map(|metrics| metrics.summary.total_throughput_megabytes_per_second) - .unwrap_or(0.0) -} - fn render_summary_card(stagger: usize, total: usize, latest_ts: Option<&str>) -> Html { let sub = match latest_ts { Some(ts) => format!("Latest: {}", format_date(ts)), @@ -467,3 +486,32 @@ fn format_date(timestamp_str: &str) -> String { Err(_) => "unknown".to_string(), } } + +fn render_hero_loading(is_dark: bool, is_slow: bool) -> Html { + let logo_src = if is_dark { + "/assets/iggy-light.png" + } else { + "/assets/iggy-dark.png" + }; + html! { + <div class="hero-v2 hero-v2-loading" aria-busy="true" aria-live="polite"> + { render_background_grid() } + <div class="hero-v2-loading-inner"> + <img + class="hero-v2-loading-mark" + src={logo_src} + alt="" + aria-hidden="true" + /> + <div class="hero-v2-loading-brand">{"Apache Iggy"}</div> + <div class="hero-v2-loading-sub">{"Benchmarks"}</div> + if is_slow { + <p class="hero-v2-loading-slow"> + {"Fetching the latest benchmark run. This can take a moment on a cold cache."} + </p> + } + <span class="visually-hidden">{"Loading benchmarks"}</span> + </div> + </div> + } +} diff --git a/core/bench/dashboard/frontend/src/components/layout/main_content.rs b/core/bench/dashboard/frontend/src/components/layout/main_content.rs index 766b53e9f..6b9a796d8 100644 --- a/core/bench/dashboard/frontend/src/components/layout/main_content.rs +++ b/core/bench/dashboard/frontend/src/components/layout/main_content.rs @@ -22,7 +22,7 @@ use crate::components::layout::sweep_view::SweepView; use crate::components::selectors::measurement_type_selector::MeasurementType; use crate::router::AppRoute; use crate::state::benchmark::use_benchmark; -use crate::state::ui::{UiAction, ViewMode, use_ui}; +use crate::state::ui::{UiAction, use_ui}; use bench_dashboard_shared::BenchmarkReportLight; use bench_report::benchmark_kind::BenchmarkKind; use std::collections::BTreeMap; @@ -40,7 +40,6 @@ pub fn main_content(props: &MainContentProps) -> Html { let _ = &props.selected_gitref; let benchmark_ctx = use_benchmark(); let ui = use_ui(); - let is_recent_view = matches!(ui.view_mode, ViewMode::RecentBenchmarks); let selected = benchmark_ctx.state.selected_benchmark.clone(); let pinned = ui.compare_pin.clone(); let entries = benchmark_ctx.state.entries.clone(); @@ -101,7 +100,6 @@ pub fn main_content(props: &MainContentProps) -> Html { is_dark, &entries, ), - (None, _) if is_recent_view => render_empty_recent(), (None, _) => render_loading(), }; @@ -121,7 +119,7 @@ fn render_single( { benchmark.title(&measurement.to_string()) } </div> <div class="chart-title-identifier"> - { benchmark.identifier_with_cpu_and_version() } + { render_benchmark_identifier(benchmark) } </div> </div> <BenchmarkMeta benchmark={benchmark.clone()} /> @@ -215,7 +213,7 @@ fn render_compare_pane( { benchmark.title(&measurement.to_string()) } </div> <div class="chart-title-identifier"> - { benchmark.identifier_with_cpu_and_version() } + { render_benchmark_identifier(benchmark) } </div> </div> <BenchmarkMeta benchmark={benchmark.clone()} /> @@ -226,27 +224,52 @@ fn render_compare_pane( } } -fn render_empty_recent() -> Html { +fn render_loading() -> Html { html! { <div class="content-wrapper"> <div class="empty-state"> <div class="empty-state-content"> - <h2>{"Select a recent benchmark"}</h2> - <p>{"Choose a benchmark from the sidebar to display performance data."}</p> + <h2>{"Loading benchmark..."}</h2> </div> </div> </div> } } -fn render_loading() -> Html { +fn render_benchmark_identifier(benchmark: &BenchmarkReportLight) -> Html { + let hardware = benchmark + .hardware + .identifier + .as_deref() + .unwrap_or("identifier"); + let cpu = benchmark.hardware.cpu_name.as_str(); + let gitref = benchmark + .params + .gitref + .as_deref() + .filter(|gitref| !gitref.is_empty()); + html! { - <div class="content-wrapper"> - <div class="empty-state"> - <div class="empty-state-content"> - <h2>{"Loading benchmark..."}</h2> - </div> - </div> - </div> + <> + <span>{format!("{hardware} @ {cpu}")}</span> + if let Some(gitref) = gitref { + <span class="chart-title-sep">{"·"}</span> + <a + class="chart-title-gitref" + href={format!("https://github.com/apache/iggy/tree/{gitref}")} + target="_blank" + rel="noopener noreferrer" + title={format!("Browse apache/iggy at {gitref}")} + > + {gitref} + <svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" + fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> + <polyline points="15 3 21 3 21 9" /> + <line x1="10" y1="14" x2="21" y2="3" /> + </svg> + </a> + } + </> } } diff --git a/core/bench/dashboard/frontend/src/components/layout/sidebar.rs b/core/bench/dashboard/frontend/src/components/layout/sidebar.rs index c71dd7457..72aba0a59 100644 --- a/core/bench/dashboard/frontend/src/components/layout/sidebar.rs +++ b/core/bench/dashboard/frontend/src/components/layout/sidebar.rs @@ -15,29 +15,79 @@ // specific language governing permissions and limitations // under the License. -use crate::components::selectors::benchmark_selector::BenchmarkSelector; -use crate::components::selectors::gitref_selector::GitrefSelector; -use crate::components::selectors::hardware_selector::HardwareSelector; +use crate::api; +use crate::components::selectors::benchmarks_list::BenchmarksList; use crate::components::selectors::param_filters_panel::ParamFiltersPanel; -use crate::components::selectors::recent_benchmarks_selector::RecentBenchmarksSelector; -use crate::state::gitref::use_gitref; -use crate::state::ui::{KindGroup, SidebarSort, UiAction, ViewMode, use_ui}; +use crate::router::AppRoute; +use crate::state::benchmark::{BenchmarkAction, recency_cmp, use_benchmark}; +use crate::state::ui::{KindGroup, SidebarSort, UiAction, use_ui}; +use bench_dashboard_shared::BenchmarkReportLight; +use gloo::console::log; +use std::cell::Cell; +use std::collections::BTreeSet; +use std::rc::Rc; use web_sys::HtmlInputElement; +use web_sys::HtmlSelectElement; +use yew::platform::spawn_local; use yew::prelude::*; +use yew_router::prelude::{use_navigator, use_route}; + +const RECENT_LIMIT: u32 = 10_000; #[derive(Properties, PartialEq)] -pub struct SidebarProps { - pub on_gitref_select: Callback<String>, - pub on_hardware_select: Callback<String>, -} +pub struct SidebarProps; #[function_component(Sidebar)] -pub fn sidebar(props: &SidebarProps) -> Html { - let gitref_ctx = use_gitref(); +pub fn sidebar(_props: &SidebarProps) -> Html { let ui = use_ui(); - let is_recent_view = matches!(ui.view_mode, ViewMode::RecentBenchmarks); - let active_kind_filter = ui.sidebar_kind_filter.clone(); - let current_sort = ui.sidebar_sort; + let benchmark_ctx = use_benchmark(); + let navigator = use_navigator(); + let route = use_route::<AppRoute>(); + + let benchmarks = use_state(Vec::<BenchmarkReportLight>::new); + let is_loading = use_state(|| true); + + { + let benchmarks_handle = benchmarks.clone(); + let is_loading_handle = is_loading.clone(); + let dispatch = benchmark_ctx.dispatch.clone(); + let navigator = navigator.clone(); + let url_has_benchmark = matches!( + route, + Some(AppRoute::Benchmark { .. }) | Some(AppRoute::Compare { .. }) + ); + let cancelled = Rc::new(Cell::new(false)); + let cancelled_async = cancelled.clone(); + use_effect_with((), move |_| { + spawn_local(async move { + match api::fetch_recent_benchmarks(Some(RECENT_LIMIT)).await { + Ok(mut data) => { + data.sort_by(|left, right| { + recency_cmp(right, left).then_with(|| right.uuid.cmp(&left.uuid)) + }); + if cancelled_async.get() { + return; + } + if !url_has_benchmark && let Some(newest) = data.first().cloned() { + if let Some(nav) = navigator.as_ref() { + nav.push(&AppRoute::Benchmark { + uuid: newest.uuid.to_string(), + }); + } + dispatch.emit(BenchmarkAction::SelectBenchmark(Box::new(Some(newest)))); + } + benchmarks_handle.set(data); + } + Err(error) => log!(format!("Sidebar: fetch_recent_benchmarks failed: {error}")), + } + if !cancelled_async.get() { + is_loading_handle.set(false); + } + }); + move || cancelled.set(true) + }); + } + let current_search = ui.sidebar_search.clone(); let on_search = { @@ -55,11 +105,6 @@ pub fn sidebar(props: &SidebarProps) -> Html { }) }; - let on_scope_change = |mode: ViewMode| { - let ui = ui.clone(); - Callback::from(move |_: MouseEvent| ui.dispatch(UiAction::SetViewMode(mode.clone()))) - }; - let on_kind_toggle = { let ui = ui.clone(); Callback::from(move |group: KindGroup| ui.dispatch(UiAction::ToggleKindFilter(group))) @@ -81,6 +126,33 @@ pub fn sidebar(props: &SidebarProps) -> Html { }) }; + let on_hardware_change = { + let ui = ui.clone(); + Callback::from(move |event: Event| { + let input: HtmlSelectElement = event.target_unchecked_into(); + let value = input.value(); + let next = if value.is_empty() { None } else { Some(value) }; + ui.dispatch(UiAction::SetHardwareFilter(next)); + }) + }; + + let on_gitref_change = { + let ui = ui.clone(); + Callback::from(move |event: Event| { + let input: HtmlSelectElement = event.target_unchecked_into(); + let value = input.value(); + let next = if value.is_empty() { None } else { Some(value) }; + ui.dispatch(UiAction::SetGitrefFilter(next)); + }) + }; + + let hardware_options = collect_hardware(&benchmarks); + let gitref_options = collect_gitrefs(&benchmarks, ui.hardware_filter.as_deref()); + let active_kind_filter = ui.sidebar_kind_filter.clone(); + let current_sort = ui.sidebar_sort; + let current_hardware = ui.hardware_filter.clone().unwrap_or_default(); + let current_gitref = ui.gitref_filter.clone().unwrap_or_default(); + html! { <aside class="sidebar"> <div class="sidebar-fixed-header"> @@ -94,7 +166,7 @@ pub fn sidebar(props: &SidebarProps) -> Html { <input type="search" class="sidebar-search-input" - placeholder="Search benchmarks, gitrefs, hardware..." + placeholder="Search benchmarks..." value={current_search.clone()} oninput={on_search} /> @@ -110,35 +182,41 @@ pub fn sidebar(props: &SidebarProps) -> Html { } </div> - <div class="sidebar-scope" role="tablist"> - <button - type="button" - role="tab" - aria-selected={(!is_recent_view).to_string()} - class={classes!("sidebar-scope-btn", (!is_recent_view).then_some("active"))} - onclick={on_scope_change(ViewMode::SingleGitref)} - > - {"Version"} - </button> - <button - type="button" - role="tab" - aria-selected={is_recent_view.to_string()} - class={classes!("sidebar-scope-btn", is_recent_view.then_some("active"))} - onclick={on_scope_change(ViewMode::RecentBenchmarks)} - > - {"Recent"} - </button> - </div> + <div class="sidebar-facet-row"> + <label class="sidebar-facet"> + <span class="sidebar-facet-label">{"Hardware"}</span> + <select class="sidebar-facet-select" onchange={on_hardware_change}> + <option value="" selected={current_hardware.is_empty()}> + {"All"} + </option> + { for hardware_options.iter().map(|option| html! { + <option + value={option.clone()} + selected={option.as_str() == current_hardware} + > + {option.clone()} + </option> + })} + </select> + </label> - if !is_recent_view { - <HardwareSelector on_hardware_select={props.on_hardware_select.clone()} /> - <GitrefSelector - gitrefs={gitref_ctx.state.gitrefs.clone()} - selected_gitref={gitref_ctx.state.selected_gitref.clone().unwrap_or_default()} - on_gitref_select={props.on_gitref_select.clone()} - /> - } + <label class="sidebar-facet"> + <span class="sidebar-facet-label">{"Version"}</span> + <select class="sidebar-facet-select" onchange={on_gitref_change}> + <option value="" selected={current_gitref.is_empty()}> + {"All"} + </option> + { for gitref_options.iter().map(|option| html! { + <option + value={option.clone()} + selected={option.as_str() == current_gitref} + > + {option.clone()} + </option> + })} + </select> + </label> + </div> <div class="sidebar-kind-chips"> { for KindGroup::all().iter().map(|group| { @@ -191,11 +269,10 @@ pub fn sidebar(props: &SidebarProps) -> Html { </div> <div class="sidebar-scrollable-content"> - if is_recent_view { - <RecentBenchmarksSelector limit={10000} /> - } else { - <BenchmarkSelector /> - } + <BenchmarksList + benchmarks={(*benchmarks).clone()} + is_loading={*is_loading} + /> </div> </aside> } @@ -247,3 +324,51 @@ fn render_compare_hint(ui: &yew::UseReducerHandle<crate::state::ui::UiState>) -> fn short_name(full: &str) -> String { full.split('(').next().unwrap_or(full).trim().to_string() } + +fn collect_hardware(benchmarks: &[BenchmarkReportLight]) -> Vec<String> { + let mut set: BTreeSet<String> = BTreeSet::new(); + for benchmark in benchmarks { + if let Some(id) = benchmark.hardware.identifier.as_deref() + && !id.is_empty() + { + set.insert(id.to_string()); + } + } + set.into_iter().collect() +} + +fn collect_gitrefs( + benchmarks: &[BenchmarkReportLight], + hardware_filter: Option<&str>, +) -> Vec<String> { + let mut newest: std::collections::HashMap<String, &BenchmarkReportLight> = + std::collections::HashMap::new(); + for benchmark in benchmarks { + if let Some(expected) = hardware_filter + && benchmark.hardware.identifier.as_deref() != Some(expected) + { + continue; + } + let Some(gitref) = benchmark.params.gitref.as_deref() else { + continue; + }; + if gitref.is_empty() { + continue; + } + newest + .entry(gitref.to_string()) + .and_modify(|existing| { + if recency_cmp(benchmark, existing).is_gt() { + *existing = benchmark; + } + }) + .or_insert(benchmark); + } + + let mut ordered: Vec<(&BenchmarkReportLight, String)> = newest + .into_iter() + .map(|(gitref, benchmark)| (benchmark, gitref)) + .collect(); + ordered.sort_by(|left, right| recency_cmp(right.0, left.0)); + ordered.into_iter().map(|(_, gitref)| gitref).collect() +} diff --git a/core/bench/dashboard/frontend/src/components/layout/top_app_bar.rs b/core/bench/dashboard/frontend/src/components/layout/top_app_bar.rs index f5893097f..6b1f28e1e 100644 --- a/core/bench/dashboard/frontend/src/components/layout/top_app_bar.rs +++ b/core/bench/dashboard/frontend/src/components/layout/top_app_bar.rs @@ -25,10 +25,8 @@ use crate::components::tooltips::server_stats_tooltip::ServerStatsTooltip; use crate::router::AppRoute; use crate::state::benchmark::{BenchmarkAction, use_benchmark}; use crate::state::ui::{TopBarPopup, UiAction, use_ui}; -use bench_dashboard_shared::BenchmarkReportLight; -use bench_report::benchmark_kind::BenchmarkKind; use gloo::console::log; -use std::collections::BTreeMap; +use gloo::timers::callback::Timeout; use yew::platform::spawn_local; use yew::prelude::*; use yew_router::prelude::{Navigator, use_navigator}; @@ -115,6 +113,26 @@ pub fn top_app_bar(props: &TopAppBarProps) -> Html { }) }; + let share_copied = use_state(|| false); + let on_share = { + let share_copied = share_copied.clone(); + Callback::from(move |_| { + let Some(window) = web_sys::window() else { + return; + }; + let url = window.location().href().unwrap_or_else(|_| String::new()); + if url.is_empty() { + return; + } + let clipboard = window.navigator().clipboard(); + let _ = clipboard.write_text(&url); + share_copied.set(true); + let share_copied_for_timer = share_copied.clone(); + let timeout = Timeout::new(1_400, move || share_copied_for_timer.set(false)); + timeout.forget(); + }) + }; + let on_embed_toggle = { let ui = ui.clone(); Callback::from(move |_| ui.dispatch(UiAction::TogglePopup(TopBarPopup::Embed))) @@ -132,18 +150,13 @@ pub fn top_app_bar(props: &TopAppBarProps) -> Html { let on_browse_click = { let navigator = navigator.clone(); - let entries = benchmark_ctx.state.entries.clone(); Callback::from(move |_| { - if let Some(uuid) = latest_uuid_from_entries(&entries) { - navigate_to_benchmark(&navigator, uuid); - } else { - let navigator = navigator.clone(); - spawn_local(async move { - if let Some(uuid) = fetch_latest_uuid().await { - navigate_to_benchmark(&navigator, uuid); - } - }); - } + let navigator = navigator.clone(); + spawn_local(async move { + if let Some(uuid) = fetch_latest_uuid().await { + navigate_to_benchmark(&navigator, uuid); + } + }); }) }; @@ -212,6 +225,20 @@ pub fn top_app_bar(props: &TopAppBarProps) -> Html { </button> } if props.show_detail_actions && selected_benchmark.is_some() { + <div class="app-bar-icon-wrap"> + <button + type="button" + class={classes!("app-bar-icon-btn", (*share_copied).then_some("active"))} + onclick={on_share} + title={if *share_copied { "Link copied" } else { "Copy share link" }} + aria-label="Copy share link" + > + { render_share_icon(*share_copied) } + </button> + if *share_copied { + <span class="app-bar-toast" role="status">{"Link copied"}</span> + } + </div> <button type="button" class="app-bar-icon-btn" @@ -252,7 +279,6 @@ pub fn top_app_bar(props: &TopAppBarProps) -> Html { <ServerStatsTooltip benchmark_report={selected_benchmark.clone()} visible={true} - view_mode={ui.view_mode.clone()} /> } </div> @@ -267,7 +293,6 @@ pub fn top_app_bar(props: &TopAppBarProps) -> Html { <BenchmarkInfoTooltip benchmark_report={benchmark.clone()} visible={true} - view_mode={ui.view_mode.clone()} /> } </div> @@ -354,6 +379,27 @@ fn render_download_icon() -> Html { } } +fn render_share_icon(copied: bool) -> Html { + if copied { + return html! { + <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" + fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"> + <polyline points="20 6 9 17 4 12" /> + </svg> + }; + } + html! { + <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" + fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="18" cy="5" r="3" /> + <circle cx="6" cy="12" r="3" /> + <circle cx="18" cy="19" r="3" /> + <line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /> + <line x1="15.41" y1="6.51" x2="8.59" y2="10.49" /> + </svg> + } +} + fn render_embed_icon() -> Html { html! { <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" @@ -364,16 +410,6 @@ fn render_embed_icon() -> Html { } } -fn latest_uuid_from_entries( - entries: &BTreeMap<BenchmarkKind, Vec<BenchmarkReportLight>>, -) -> Option<String> { - entries - .values() - .flatten() - .max_by(|left, right| left.timestamp.cmp(&right.timestamp)) - .map(|benchmark| benchmark.uuid.to_string()) -} - async fn fetch_latest_uuid() -> Option<String> { match api::fetch_recent_benchmarks(Some(1)).await { Ok(recent) => recent.into_iter().next().map(|b| b.uuid.to_string()), diff --git a/core/bench/dashboard/frontend/src/components/selectors/benchmark_selector.rs b/core/bench/dashboard/frontend/src/components/selectors/benchmarks_list.rs similarity index 73% rename from core/bench/dashboard/frontend/src/components/selectors/benchmark_selector.rs rename to core/bench/dashboard/frontend/src/components/selectors/benchmarks_list.rs index 54f15d4ee..84a9c625f 100644 --- a/core/bench/dashboard/frontend/src/components/selectors/benchmark_selector.rs +++ b/core/bench/dashboard/frontend/src/components/selectors/benchmarks_list.rs @@ -18,21 +18,22 @@ use crate::components::selectors::dense_benchmark_row::DenseBenchmarkRow; use crate::format::nan_safe_cmp; use crate::router::AppRoute; -use crate::state::benchmark::{BenchmarkAction, use_benchmark}; +use crate::state::benchmark::{BenchmarkAction, recency_cmp, use_benchmark}; use crate::state::ui::{KindGroup, SidebarSort, UiAction, use_ui}; use bench_dashboard_shared::BenchmarkReportLight; use bench_report::benchmark_kind::BenchmarkKind; -use chrono::DateTime; -use std::cmp::Ordering; -use std::collections::{BTreeMap, HashSet}; +use std::collections::HashSet; use yew::prelude::*; -use yew_router::prelude::use_navigator; +use yew_router::prelude::{Navigator, use_navigator}; -#[derive(Properties, PartialEq, Default)] -pub struct BenchmarkSelectorProps; +#[derive(Properties, PartialEq)] +pub struct BenchmarksListProps { + pub benchmarks: Vec<BenchmarkReportLight>, + pub is_loading: bool, +} -#[function_component(BenchmarkSelector)] -pub fn benchmark_selector(_props: &BenchmarkSelectorProps) -> Html { +#[function_component(BenchmarksList)] +pub fn benchmarks_list(props: &BenchmarksListProps) -> Html { let benchmark_ctx = use_benchmark(); let ui_state = use_ui(); let navigator = use_navigator(); @@ -44,24 +45,13 @@ pub fn benchmark_selector(_props: &BenchmarkSelectorProps) -> Html { .as_ref() .map(|selected| selected.uuid); - let filters = ui_state.param_filters.clone(); + let param_filters = ui_state.param_filters.clone(); let search = ui_state.sidebar_search.to_lowercase(); let kind_filter = ui_state.sidebar_kind_filter.clone(); + let hardware_filter = ui_state.hardware_filter.clone(); + let gitref_filter = ui_state.gitref_filter.clone(); let sort = ui_state.sidebar_sort; - let visible: Vec<BenchmarkReportLight> = benchmark_ctx - .state - .entries - .values() - .flatten() - .filter(|benchmark| filters.matches(benchmark)) - .filter(|benchmark| kind_filter_matches(&kind_filter, benchmark.params.benchmark_kind)) - .filter(|benchmark| search_matches(&search, benchmark)) - .cloned() - .collect(); - let hidden_by_filter = total_benchmarks(&benchmark_ctx.state.entries) - visible.len(); - let sorted = sort_benchmarks(visible, sort); - let on_select = { let dispatch = benchmark_ctx.dispatch.clone(); let navigator = navigator.clone(); @@ -95,7 +85,7 @@ pub fn benchmark_selector(_props: &BenchmarkSelectorProps) -> Html { && pinned == &clicked_uuid && let Some(selected) = selected_uuid.as_ref() { - navigate( + push_route( &navigator, AppRoute::Benchmark { uuid: selected.clone(), @@ -107,7 +97,7 @@ pub fn benchmark_selector(_props: &BenchmarkSelectorProps) -> Html { if let Some(selected) = selected_uuid && selected != clicked_uuid { - navigate( + push_route( &navigator, AppRoute::Compare { left: selected, @@ -124,18 +114,31 @@ pub fn benchmark_selector(_props: &BenchmarkSelectorProps) -> Html { }) }; - if sorted.is_empty() { + if props.is_loading { + return render_skeleton(); + } + + let visible: Vec<BenchmarkReportLight> = props + .benchmarks + .iter() + .filter(|benchmark| param_filters.matches(benchmark)) + .filter(|benchmark| kind_filter_matches(&kind_filter, benchmark.params.benchmark_kind)) + .filter(|benchmark| hardware_matches(hardware_filter.as_deref(), benchmark)) + .filter(|benchmark| gitref_matches(gitref_filter.as_deref(), benchmark)) + .filter(|benchmark| search_matches(&search, benchmark)) + .cloned() + .collect(); + + if visible.is_empty() { return html! { <div class="dense-list-empty"> - if hidden_by_filter > 0 { - <p>{format!("{hidden_by_filter} benchmark(s) hidden. Adjust filters or search.")}</p> - } else { - <p>{"No benchmarks for this gitref yet."}</p> - } + <p>{"No benchmarks match the current filters."}</p> </div> }; } + let sorted = sort_benchmarks(visible, sort); + html! { <div class="dense-list"> { for sorted.iter().map(|benchmark| html! { @@ -145,19 +148,35 @@ pub fn benchmark_selector(_props: &BenchmarkSelectorProps) -> Html { pinned_uuid={pinned_uuid} on_select={on_select.clone()} on_toggle_pin={on_toggle_pin.clone()} - show_timestamp={false} + show_timestamp={true} /> })} </div> } } -fn navigate(navigator: &Option<yew_router::prelude::Navigator>, route: AppRoute) { +fn push_route(navigator: &Option<Navigator>, route: AppRoute) { if let Some(nav) = navigator.as_ref() { nav.push(&route); } } +fn render_skeleton() -> Html { + html! { + <div class="dense-list"> + { for (0..5).map(|index| html! { + <div class="dense-row-skeleton" style={format!("--stagger: {index}")}> + <span class="skeleton-dot" /> + <div class="skeleton-body"> + <span class="skeleton-line title" /> + <span class="skeleton-line meta" /> + </div> + </div> + })} + </div> + } +} + fn kind_filter_matches(filter: &HashSet<KindGroup>, kind: BenchmarkKind) -> bool { if filter.is_empty() { return true; @@ -165,6 +184,20 @@ fn kind_filter_matches(filter: &HashSet<KindGroup>, kind: BenchmarkKind) -> bool filter.iter().any(|group| group.matches(kind)) } +fn hardware_matches(filter: Option<&str>, benchmark: &BenchmarkReportLight) -> bool { + match filter { + Some(expected) => benchmark.hardware.identifier.as_deref() == Some(expected), + None => true, + } +} + +fn gitref_matches(filter: Option<&str>, benchmark: &BenchmarkReportLight) -> bool { + match filter { + Some(expected) => benchmark.params.gitref.as_deref() == Some(expected), + None => true, + } +} + fn search_matches(query: &str, benchmark: &BenchmarkReportLight) -> bool { if query.is_empty() { return true; @@ -191,19 +224,18 @@ fn search_matches(query: &str, benchmark: &BenchmarkReportLight) -> bool { { return true; } + if benchmark.timestamp.to_lowercase().contains(query) { + return true; + } false } -fn total_benchmarks(entries: &BTreeMap<BenchmarkKind, Vec<BenchmarkReportLight>>) -> usize { - entries.values().map(|values| values.len()).sum() -} - fn sort_benchmarks( mut benchmarks: Vec<BenchmarkReportLight>, sort: SidebarSort, ) -> Vec<BenchmarkReportLight> { benchmarks.sort_by(|left, right| match sort { - SidebarSort::MostRecent => compare_timestamps(&right.timestamp, &left.timestamp), + SidebarSort::MostRecent => recency_cmp(right, left), SidebarSort::PeakThroughput => nan_safe_cmp(throughput(right), throughput(left)), SidebarSort::LowestP99 => nan_safe_cmp(p99(left), p99(right)), SidebarSort::Name => left.params.pretty_name.cmp(&right.params.pretty_name), @@ -211,16 +243,6 @@ fn sort_benchmarks( benchmarks } -fn compare_timestamps(left: &str, right: &str) -> Ordering { - match ( - DateTime::parse_from_rfc3339(left), - DateTime::parse_from_rfc3339(right), - ) { - (Ok(left_time), Ok(right_time)) => left_time.cmp(&right_time), - _ => Ordering::Equal, - } -} - fn throughput(benchmark: &BenchmarkReportLight) -> f64 { benchmark .group_metrics diff --git a/core/bench/dashboard/frontend/src/components/selectors/gitref_selector.rs b/core/bench/dashboard/frontend/src/components/selectors/gitref_selector.rs deleted file mode 100644 index 440f2e5b3..000000000 --- a/core/bench/dashboard/frontend/src/components/selectors/gitref_selector.rs +++ /dev/null @@ -1,63 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -use wasm_bindgen::JsCast; -use web_sys::HtmlSelectElement; -use yew::prelude::*; - -#[derive(Properties, PartialEq)] -pub struct GitrefSelectorProps { - pub gitrefs: Vec<String>, - pub selected_gitref: String, - pub on_gitref_select: Callback<String>, -} - -#[function_component(GitrefSelector)] -pub fn gitref_selector(props: &GitrefSelectorProps) -> Html { - let onchange = { - let on_gitref_select = props.on_gitref_select.clone(); - Callback::from(move |e: Event| { - if let Some(select) = e - .target() - .and_then(|t| t.dyn_into::<HtmlSelectElement>().ok()) - { - let gitref = select.value(); - on_gitref_select.emit(gitref); - } - }) - }; - - html! { - <div class="gitref-select"> - <h3>{"Version"}</h3> - <select {onchange} value={props.selected_gitref.clone()}> - { - props.gitrefs.iter().map(|gitref| { - html! { - <option - value={gitref.clone()} - selected={gitref == &props.selected_gitref} - > - {gitref} - </option> - } - }).collect::<Html>() - } - </select> - </div> - } -} diff --git a/core/bench/dashboard/frontend/src/components/selectors/hardware_selector.rs b/core/bench/dashboard/frontend/src/components/selectors/hardware_selector.rs deleted file mode 100644 index fc2a9eb4a..000000000 --- a/core/bench/dashboard/frontend/src/components/selectors/hardware_selector.rs +++ /dev/null @@ -1,61 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -use crate::state::hardware::use_hardware; -use gloo::console::log; -use yew::prelude::*; - -#[derive(Properties, PartialEq)] -pub struct HardwareSelectorProps { - pub on_hardware_select: Callback<String>, -} - -#[function_component(HardwareSelector)] -pub fn hardware_selector(props: &HardwareSelectorProps) -> Html { - let hardware_ctx = use_hardware(); - - let on_hardware_change = create_on_hardware_change_callback(props.on_hardware_select.clone()); - - html! { - <div class="hardware-selector"> - <h3>{"Hardware"}</h3> - <select - onchange={on_hardware_change.clone()} - > - { for hardware_ctx.state.hardware_list.iter().map(|hardware| html! { - <option - value={hardware.identifier.clone().unwrap_or_else(|| "Unknown".to_string())} - selected={hardware_ctx.state.selected_hardware == Some(hardware.identifier.clone().unwrap_or_else(|| "Unknown".to_string()))} - > - {format!("{} @ {}", hardware.identifier.clone().unwrap_or_else(|| "Unknown".to_string()), &hardware.cpu_name)} - </option> - }) } - </select> - </div> - } -} - -fn create_on_hardware_change_callback(on_hardware_select: Callback<String>) -> Callback<Event> { - Callback::from(move |e: Event| { - let target = e.target_dyn_into::<web_sys::HtmlSelectElement>(); - if let Some(select) = target { - let value = select.value(); - log!(format!("Hardware selected via dropdown: {}", value)); - on_hardware_select.emit(value); - } - }) -} diff --git a/core/bench/dashboard/frontend/src/components/selectors/mod.rs b/core/bench/dashboard/frontend/src/components/selectors/mod.rs index 7c0bdef59..6eebff182 100644 --- a/core/bench/dashboard/frontend/src/components/selectors/mod.rs +++ b/core/bench/dashboard/frontend/src/components/selectors/mod.rs @@ -15,10 +15,7 @@ // specific language governing permissions and limitations // under the License. -pub mod benchmark_selector; +pub mod benchmarks_list; pub mod dense_benchmark_row; -pub mod gitref_selector; -pub mod hardware_selector; pub mod measurement_type_selector; pub mod param_filters_panel; -pub mod recent_benchmarks_selector; diff --git a/core/bench/dashboard/frontend/src/components/selectors/recent_benchmarks_selector.rs b/core/bench/dashboard/frontend/src/components/selectors/recent_benchmarks_selector.rs deleted file mode 100644 index 0c06ee5ab..000000000 --- a/core/bench/dashboard/frontend/src/components/selectors/recent_benchmarks_selector.rs +++ /dev/null @@ -1,302 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -use crate::api; -use crate::components::selectors::dense_benchmark_row::DenseBenchmarkRow; -use crate::format::nan_safe_cmp; -use crate::router::AppRoute; -use crate::state::benchmark::{BenchmarkAction, use_benchmark}; -use crate::state::ui::{KindGroup, SidebarSort, UiAction, use_ui}; -use bench_dashboard_shared::BenchmarkReportLight; -use bench_report::benchmark_kind::BenchmarkKind; -use chrono::DateTime; -use gloo::console::log; -use std::cell::Cell; -use std::cmp::Ordering; -use std::collections::HashSet; -use std::rc::Rc; -use yew::platform::spawn_local; -use yew::prelude::*; -use yew_router::prelude::{Navigator, use_navigator}; - -#[derive(Properties, PartialEq)] -pub struct RecentBenchmarksSelectorProps { - pub limit: u32, -} - -#[function_component(RecentBenchmarksSelector)] -pub fn recent_benchmarks_selector(props: &RecentBenchmarksSelectorProps) -> Html { - let benchmark_ctx = use_benchmark(); - let ui_state = use_ui(); - let navigator = use_navigator(); - - let pinned_uuid = ui_state.compare_pin.as_ref().map(|pin| pin.uuid); - let selected_uuid = benchmark_ctx - .state - .selected_benchmark - .as_ref() - .map(|selected| selected.uuid); - - let filters = ui_state.param_filters.clone(); - let search = ui_state.sidebar_search.to_lowercase(); - let kind_filter = ui_state.sidebar_kind_filter.clone(); - let sort = ui_state.sidebar_sort; - - let recent_benchmarks = use_state(Vec::<BenchmarkReportLight>::new); - let is_loading = use_state(|| true); - - { - let recent_benchmarks = recent_benchmarks.clone(); - let is_loading = is_loading.clone(); - let limit = props.limit; - let dispatch = benchmark_ctx.dispatch.clone(); - let navigator = navigator.clone(); - let has_selection = benchmark_ctx.state.selected_benchmark.is_some(); - - let cancelled = Rc::new(Cell::new(false)); - let cancelled_async = cancelled.clone(); - use_effect_with((), move |_| { - spawn_local(async move { - match api::fetch_recent_benchmarks(Some(limit)).await { - Ok(mut data) => { - data.sort_by(|left, right| { - compare_timestamps(&right.timestamp, &left.timestamp) - }); - if cancelled_async.get() { - return; - } - if !has_selection && let Some(most_recent) = data.first().cloned() { - if let Some(nav) = navigator.as_ref() { - nav.push(&AppRoute::Benchmark { - uuid: most_recent.uuid.to_string(), - }); - } - dispatch.emit(BenchmarkAction::SelectBenchmark(Box::new(Some( - most_recent, - )))); - } - recent_benchmarks.set(data); - } - Err(error) => log!(format!("Error fetching recent benchmarks: {}", error)), - } - if !cancelled_async.get() { - is_loading.set(false); - } - }); - move || cancelled.set(true) - }); - } - - let on_select = { - let dispatch = benchmark_ctx.dispatch.clone(); - let navigator = navigator.clone(); - Callback::from(move |benchmark: BenchmarkReportLight| { - if let Some(nav) = navigator.as_ref() { - nav.push(&AppRoute::Benchmark { - uuid: benchmark.uuid.to_string(), - }); - } - dispatch.emit(BenchmarkAction::SelectBenchmark(Box::new(Some(benchmark)))); - }) - }; - - let on_toggle_pin = { - let ui_state = ui_state.clone(); - let navigator = navigator.clone(); - let benchmark_ctx = benchmark_ctx.clone(); - Callback::from(move |benchmark: BenchmarkReportLight| { - let clicked_uuid = benchmark.uuid.to_string(); - let selected_uuid = benchmark_ctx - .state - .selected_benchmark - .as_ref() - .map(|selected| selected.uuid.to_string()); - let pinned_uuid = ui_state - .compare_pin - .as_ref() - .map(|pin| pin.uuid.to_string()); - - if let Some(pinned) = pinned_uuid.as_ref() - && pinned == &clicked_uuid - && let Some(selected) = selected_uuid.as_ref() - { - push_route( - &navigator, - AppRoute::Benchmark { - uuid: selected.clone(), - }, - ); - return; - } - - if let Some(selected) = selected_uuid - && selected != clicked_uuid - { - push_route( - &navigator, - AppRoute::Compare { - left: selected, - right: clicked_uuid, - }, - ); - return; - } - - let same_pin = - ui_state.compare_pin.as_ref().map(|pin| pin.uuid) == Some(benchmark.uuid); - let next = if same_pin { None } else { Some(benchmark) }; - ui_state.dispatch(UiAction::SetComparePin(Box::new(next))); - }) - }; - - if *is_loading { - return render_skeleton(); - } - - let visible: Vec<BenchmarkReportLight> = (*recent_benchmarks) - .iter() - .filter(|benchmark| filters.matches(benchmark)) - .filter(|benchmark| kind_filter_matches(&kind_filter, benchmark.params.benchmark_kind)) - .filter(|benchmark| search_matches(&search, benchmark)) - .cloned() - .collect(); - - if visible.is_empty() { - return html! { - <div class="dense-list-empty"> - <p>{"No benchmarks match the current search or filters."}</p> - </div> - }; - } - - let sorted = sort_benchmarks(visible, sort); - - html! { - <div class="dense-list"> - { for sorted.iter().map(|benchmark| html! { - <DenseBenchmarkRow - benchmark={benchmark.clone()} - selected_uuid={selected_uuid} - pinned_uuid={pinned_uuid} - on_select={on_select.clone()} - on_toggle_pin={on_toggle_pin.clone()} - show_timestamp={true} - /> - })} - </div> - } -} - -fn push_route(navigator: &Option<Navigator>, route: AppRoute) { - if let Some(nav) = navigator.as_ref() { - nav.push(&route); - } -} - -fn render_skeleton() -> Html { - html! { - <div class="dense-list"> - { for (0..5).map(|index| html! { - <div class="dense-row-skeleton" style={format!("--stagger: {index}")}> - <span class="skeleton-dot" /> - <div class="skeleton-body"> - <span class="skeleton-line title" /> - <span class="skeleton-line meta" /> - </div> - </div> - })} - </div> - } -} - -fn kind_filter_matches(filter: &HashSet<KindGroup>, kind: BenchmarkKind) -> bool { - if filter.is_empty() { - return true; - } - filter.iter().any(|group| group.matches(kind)) -} - -fn search_matches(query: &str, benchmark: &BenchmarkReportLight) -> bool { - if query.is_empty() { - return true; - } - let kind_label = benchmark.params.benchmark_kind.to_string().to_lowercase(); - if kind_label.contains(query) { - return true; - } - if benchmark.params.pretty_name.to_lowercase().contains(query) { - return true; - } - if let Some(remark) = benchmark.params.remark.as_deref() - && remark.to_lowercase().contains(query) - { - return true; - } - if let Some(gitref) = benchmark.params.gitref.as_deref() - && gitref.to_lowercase().contains(query) - { - return true; - } - if let Some(hardware) = benchmark.hardware.identifier.as_deref() - && hardware.to_lowercase().contains(query) - { - return true; - } - if benchmark.timestamp.to_lowercase().contains(query) { - return true; - } - false -} - -fn sort_benchmarks( - mut benchmarks: Vec<BenchmarkReportLight>, - sort: SidebarSort, -) -> Vec<BenchmarkReportLight> { - benchmarks.sort_by(|left, right| match sort { - SidebarSort::MostRecent => compare_timestamps(&right.timestamp, &left.timestamp), - SidebarSort::PeakThroughput => nan_safe_cmp(throughput(right), throughput(left)), - SidebarSort::LowestP99 => nan_safe_cmp(p99(left), p99(right)), - SidebarSort::Name => left.params.pretty_name.cmp(&right.params.pretty_name), - }); - benchmarks -} - -fn compare_timestamps(left: &str, right: &str) -> Ordering { - match ( - DateTime::parse_from_rfc3339(left), - DateTime::parse_from_rfc3339(right), - ) { - (Ok(left_time), Ok(right_time)) => left_time.cmp(&right_time), - _ => Ordering::Equal, - } -} - -fn throughput(benchmark: &BenchmarkReportLight) -> f64 { - benchmark - .group_metrics - .first() - .map(|metrics| metrics.summary.total_throughput_megabytes_per_second) - .unwrap_or(0.0) -} - -fn p99(benchmark: &BenchmarkReportLight) -> f64 { - benchmark - .group_metrics - .first() - .map(|metrics| metrics.summary.average_p99_latency_ms) - .unwrap_or(f64::INFINITY) -} diff --git a/core/bench/dashboard/frontend/src/components/tooltips/benchmark_info_tooltip.rs b/core/bench/dashboard/frontend/src/components/tooltips/benchmark_info_tooltip.rs index 255e71442..d56e06bb1 100644 --- a/core/bench/dashboard/frontend/src/components/tooltips/benchmark_info_tooltip.rs +++ b/core/bench/dashboard/frontend/src/components/tooltips/benchmark_info_tooltip.rs @@ -15,7 +15,6 @@ // specific language governing permissions and limitations // under the License. -use crate::state::ui::ViewMode; use bench_dashboard_shared::BenchmarkReportLight; use gloo::timers::callback::Timeout; use web_sys::window; @@ -25,7 +24,6 @@ use yew::prelude::*; pub struct BenchmarkInfoTooltipProps { pub benchmark_report: Option<BenchmarkReportLight>, pub visible: bool, - pub view_mode: ViewMode, } #[function_component(BenchmarkInfoTooltip)] @@ -37,7 +35,6 @@ pub fn benchmark_info_tooltip(props: &BenchmarkInfoTooltipProps) -> Html { let benchmark_report = props.benchmark_report.as_ref().unwrap(); let hardware = &benchmark_report.hardware; let params = &benchmark_report.params; - let is_trend_view = matches!(props.view_mode, ViewMode::GitrefTrend); let notification_visible = use_state(|| false); let onclick = { @@ -73,13 +70,7 @@ pub fn benchmark_info_tooltip(props: &BenchmarkInfoTooltipProps) -> Html { <div class="tooltip-section"> <h4>{"Benchmark Parameters"}</h4> <div class="tooltip-content"> - {if !is_trend_view { - html! { - <p><strong>{"Time: "}</strong>{&benchmark_report.timestamp}</p> - } - } else { - html! {} - }} + <p><strong>{"Time: "}</strong>{&benchmark_report.timestamp}</p> <p><strong>{"Kind: "}</strong>{¶ms.benchmark_kind}</p> <p><strong>{"Transport: "}</strong>{¶ms.transport}</p> <p><strong>{"Messages: "}</strong>{format!("{} x {} ({} bytes)", @@ -101,16 +92,8 @@ pub fn benchmark_info_tooltip(props: &BenchmarkInfoTooltipProps) -> Html { ) } }</p> - {if !is_trend_view { - html! { - <> - <p><strong>{"Git ref: "}</strong>{params.gitref.clone()}</p> - <p><strong>{"Git ref date: "}</strong>{params.gitref_date.clone()}</p> - </> - } - } else { - html! {} - }} + <p><strong>{"Git ref: "}</strong>{params.gitref.clone()}</p> + <p><strong>{"Git ref date: "}</strong>{params.gitref_date.clone()}</p> </div> </div> <div class="tooltip-section"> diff --git a/core/bench/dashboard/frontend/src/components/tooltips/server_stats_tooltip.rs b/core/bench/dashboard/frontend/src/components/tooltips/server_stats_tooltip.rs index 878d8ca19..20587c555 100644 --- a/core/bench/dashboard/frontend/src/components/tooltips/server_stats_tooltip.rs +++ b/core/bench/dashboard/frontend/src/components/tooltips/server_stats_tooltip.rs @@ -15,7 +15,6 @@ // specific language governing permissions and limitations // under the License. -use crate::state::ui::ViewMode; use bench_dashboard_shared::BenchmarkReportLight; use yew::prelude::*; @@ -23,7 +22,6 @@ use yew::prelude::*; pub struct ServerStatsTooltipProps { pub benchmark_report: Option<BenchmarkReportLight>, pub visible: bool, - pub view_mode: ViewMode, } #[function_component(ServerStatsTooltip)] diff --git a/core/bench/dashboard/frontend/src/main.rs b/core/bench/dashboard/frontend/src/main.rs index fa5479fc8..f36373842 100644 --- a/core/bench/dashboard/frontend/src/main.rs +++ b/core/bench/dashboard/frontend/src/main.rs @@ -23,6 +23,7 @@ mod format; mod hooks; mod router; mod state; +mod version; use crate::{ components::{app_content::AppContent, footer::Footer}, diff --git a/core/bench/dashboard/frontend/src/state/benchmark.rs b/core/bench/dashboard/frontend/src/state/benchmark.rs index 216715c64..4838b4979 100644 --- a/core/bench/dashboard/frontend/src/state/benchmark.rs +++ b/core/bench/dashboard/frontend/src/state/benchmark.rs @@ -16,10 +16,12 @@ // under the License. use crate::format::{finite_or, nan_safe_cmp}; +use crate::version::{SemverRecency, parse_semver_recency}; use bench_dashboard_shared::BenchmarkReportLight; use bench_report::benchmark_kind::BenchmarkKind; -use chrono::{DateTime, Duration}; +use chrono::DateTime; use gloo::console::log; +use std::cmp::Ordering; use std::collections::BTreeMap; use std::rc::Rc; use yew::prelude::*; @@ -258,42 +260,117 @@ impl BenchmarkState { } } -/// From a set of benchmarks, pick the one that best represents "current peak": -/// restrict to runs within 2h of the latest timestamp (avoids UTC day-split artifacts -/// on overnight benchmark sweeps), then pick lowest P99 latency; ties broken by -/// highest total throughput. +/// Pick the best benchmark from the most recent sweep. +/// +/// "Sweep" = runs sharing the newest (hardware, gitref). Ranked by lowest P99, +/// then P99.9, then P99.99, then highest total throughput. +/// NaN values rank worst so corrupt runs never win. pub fn pick_best_from_recent_batch( benchmarks: &[&BenchmarkReportLight], ) -> Option<BenchmarkReportLight> { - let latest_timestamp = benchmarks - .iter() - .filter_map(|benchmark| DateTime::parse_from_rfc3339(&benchmark.timestamp).ok()) - .max()?; - let window_start = latest_timestamp - Duration::hours(2); + let mut sweep = latest_sweep(benchmarks); + sweep.sort_by_key(rank_sweep_candidate); + sweep.first().map(|report| (*report).clone()) +} +pub fn latest_sweep<'a>(benchmarks: &[&'a BenchmarkReportLight]) -> Vec<&'a BenchmarkReportLight> { + let Some(newest) = benchmarks + .iter() + .copied() + .max_by(|left, right| recency_cmp(left, right)) + else { + return Vec::new(); + }; + let sweep_key = sweep_key_of(newest); benchmarks .iter() .copied() - .filter(|benchmark| { - DateTime::parse_from_rfc3339(&benchmark.timestamp) - .map(|timestamp| timestamp >= window_start) - .unwrap_or(false) - }) - .min_by(|left, right| { - let metrics = |report: &BenchmarkReportLight| { - report.group_metrics.first().map(|group| { - ( - finite_or(group.summary.average_p99_latency_ms, f64::INFINITY), - finite_or(group.summary.total_throughput_megabytes_per_second, 0.0), - ) - }) - }; - let (left_p99, left_throughput) = metrics(left).unwrap_or((f64::INFINITY, 0.0)); - let (right_p99, right_throughput) = metrics(right).unwrap_or((f64::INFINITY, 0.0)); - nan_safe_cmp(left_p99, right_p99) - .then_with(|| nan_safe_cmp(right_throughput, left_throughput)) - }) - .cloned() + .filter(|benchmark| sweep_key_of(benchmark) == sweep_key) + .collect() +} + +/// Order benchmarks by "most recent" using semver-aware recency. +/// +/// Benchmarks whose gitref parses as a semver-like tag (e.g. `0.7.0`, +/// `0.7.0-edge.1`) are newer than any plain-commit run. Within each +/// group we tie-break on RFC3339 timestamp so rebuilds of the same +/// gitref still order by wall clock. +pub fn recency_cmp(left: &BenchmarkReportLight, right: &BenchmarkReportLight) -> Ordering { + let left_semver = left.params.gitref.as_deref().and_then(parse_semver_recency); + let right_semver = right + .params + .gitref + .as_deref() + .and_then(parse_semver_recency); + compare_semver_group(left_semver.as_ref(), right_semver.as_ref()) + .then_with(|| timestamp_cmp(&left.timestamp, &right.timestamp)) +} + +fn compare_semver_group(left: Option<&SemverRecency>, right: Option<&SemverRecency>) -> Ordering { + match (left, right) { + (Some(l), Some(r)) => l.cmp(r), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + } +} + +fn timestamp_cmp(left: &str, right: &str) -> Ordering { + match ( + DateTime::parse_from_rfc3339(left), + DateTime::parse_from_rfc3339(right), + ) { + (Ok(l), Ok(r)) => l.cmp(&r), + _ => Ordering::Equal, + } +} + +fn sweep_key_of(benchmark: &BenchmarkReportLight) -> (Option<String>, Option<String>) { + ( + benchmark.hardware.identifier.clone(), + benchmark.params.gitref.clone(), + ) +} + +fn rank_sweep_candidate( + benchmark: &&BenchmarkReportLight, +) -> (FloatKey, FloatKey, FloatKey, FloatKey) { + let summary = benchmark.group_metrics.first().map(|group| &group.summary); + let p99 = summary + .map(|summary| finite_or(summary.average_p99_latency_ms, f64::INFINITY)) + .unwrap_or(f64::INFINITY); + let p999 = summary + .map(|summary| finite_or(summary.average_p999_latency_ms, f64::INFINITY)) + .unwrap_or(f64::INFINITY); + let p9999 = summary + .map(|summary| finite_or(summary.average_p9999_latency_ms, f64::INFINITY)) + .unwrap_or(f64::INFINITY); + let throughput = summary + .map(|summary| finite_or(summary.total_throughput_megabytes_per_second, 0.0)) + .unwrap_or(0.0); + ( + FloatKey(p99), + FloatKey(p999), + FloatKey(p9999), + FloatKey(-throughput), + ) +} + +#[derive(Clone, Copy, PartialEq)] +struct FloatKey(f64); + +impl Eq for FloatKey {} + +impl Ord for FloatKey { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + nan_safe_cmp(self.0, other.0) + } +} + +impl PartialOrd for FloatKey { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } } #[derive(Debug)] @@ -356,54 +433,281 @@ mod tests { } #[test] - fn given_runs_within_window_when_picking_should_return_lowest_p99() { - let a = benchmark("2026-04-21T10:00:00Z", 5.0, 100.0); - let b = benchmark("2026-04-21T10:30:00Z", 2.0, 80.0); - let c = benchmark("2026-04-21T11:00:00Z", 3.5, 120.0); + fn given_sweep_runs_when_picking_should_return_lowest_p99() { + let a = run( + "2026-04-21T10:00:00Z", + "hw-a", + "0.7.0", + [5.0, 10.0, 20.0], + 100.0, + ); + let b = run( + "2026-04-21T10:30:00Z", + "hw-a", + "0.7.0", + [2.0, 8.0, 18.0], + 80.0, + ); + let c = run( + "2026-04-21T11:00:00Z", + "hw-a", + "0.7.0", + [3.5, 9.0, 19.0], + 120.0, + ); let picked = pick_best_from_recent_batch(&[&a, &b, &c]).expect("expected a pick"); assert_eq!(picked.timestamp, b.timestamp); } #[test] - fn given_tie_on_p99_when_picking_should_break_tie_on_throughput() { - let low_tput = benchmark("2026-04-21T10:00:00Z", 2.0, 100.0); - let high_tput = benchmark("2026-04-21T10:10:00Z", 2.0, 200.0); - let picked = pick_best_from_recent_batch(&[&low_tput, &high_tput]).expect("expected pick"); + fn given_tie_on_p99_when_picking_should_break_on_p999() { + let worse_tail = run( + "2026-04-21T10:00:00Z", + "hw-a", + "0.7.0", + [2.0, 20.0, 30.0], + 500.0, + ); + let better_tail = run( + "2026-04-21T10:10:00Z", + "hw-a", + "0.7.0", + [2.0, 12.0, 30.0], + 90.0, + ); + let picked = pick_best_from_recent_batch(&[&worse_tail, &better_tail]).expect("pick"); + assert_eq!(picked.timestamp, better_tail.timestamp); + } + + #[test] + fn given_tie_on_p99_and_p999_when_picking_should_break_on_p9999() { + let worse = run( + "2026-04-21T10:00:00Z", + "hw-a", + "0.7.0", + [2.0, 10.0, 30.0], + 500.0, + ); + let better = run( + "2026-04-21T10:10:00Z", + "hw-a", + "0.7.0", + [2.0, 10.0, 20.0], + 90.0, + ); + let picked = pick_best_from_recent_batch(&[&worse, &better]).expect("pick"); + assert_eq!(picked.timestamp, better.timestamp); + } + + #[test] + fn given_tie_on_all_latencies_when_picking_should_break_on_throughput() { + let low_tput = run( + "2026-04-21T10:00:00Z", + "hw-a", + "0.7.0", + [2.0, 10.0, 20.0], + 100.0, + ); + let high_tput = run( + "2026-04-21T10:10:00Z", + "hw-a", + "0.7.0", + [2.0, 10.0, 20.0], + 200.0, + ); + let picked = pick_best_from_recent_batch(&[&low_tput, &high_tput]).expect("pick"); assert_eq!(picked.timestamp, high_tput.timestamp); } #[test] - fn given_runs_outside_window_when_picking_should_ignore_old_runs() { - let old = benchmark("2026-04-20T10:00:00Z", 1.0, 500.0); - let fresh_slow = benchmark("2026-04-21T12:00:00Z", 10.0, 100.0); - let fresh_fast = benchmark("2026-04-21T12:30:00Z", 5.0, 90.0); - let picked = pick_best_from_recent_batch(&[&old, &fresh_slow, &fresh_fast]) - .expect("expected a pick"); - assert_eq!(picked.timestamp, fresh_fast.timestamp); + fn given_runs_from_older_release_when_picking_should_restrict_to_latest_sweep() { + let old_release = run( + "2025-01-15T10:00:00Z", + "hw-a", + "0.5.0", + [1.0, 5.0, 10.0], + 500.0, + ); + let current_slow = run( + "2026-04-21T12:00:00Z", + "hw-a", + "0.7.0", + [10.0, 15.0, 25.0], + 100.0, + ); + let current_fast = run( + "2026-04-21T12:30:00Z", + "hw-a", + "0.7.0", + [5.0, 12.0, 22.0], + 90.0, + ); + let picked = pick_best_from_recent_batch(&[&old_release, ¤t_slow, ¤t_fast]) + .expect("pick"); + assert_eq!(picked.timestamp, current_fast.timestamp); + } + + #[test] + fn given_runs_on_different_hardware_when_picking_should_restrict_to_latest_sweep() { + let other_hw = run( + "2026-04-21T12:40:00Z", + "hw-b", + "0.7.0", + [1.0, 5.0, 10.0], + 500.0, + ); + let current = run( + "2026-04-21T12:30:00Z", + "hw-a", + "0.7.0", + [5.0, 12.0, 22.0], + 90.0, + ); + let picked = pick_best_from_recent_batch(&[&other_hw, ¤t]).expect("pick"); + assert_eq!(picked.timestamp, other_hw.timestamp); + } + + #[test] + fn given_latest_sweep_helper_when_called_should_filter_to_newest_hardware_gitref_group() { + let old = run( + "2026-04-20T10:00:00Z", + "hw-a", + "0.6.0", + [1.0, 5.0, 10.0], + 100.0, + ); + let latest_a = run( + "2026-04-21T12:00:00Z", + "hw-a", + "0.7.0", + [5.0, 10.0, 20.0], + 100.0, + ); + let latest_b = run( + "2026-04-21T12:30:00Z", + "hw-a", + "0.7.0", + [3.0, 8.0, 18.0], + 120.0, + ); + let sweep = latest_sweep(&[&old, &latest_a, &latest_b]); + assert_eq!(sweep.len(), 2); + assert!( + sweep + .iter() + .any(|report| report.timestamp == latest_a.timestamp) + ); + assert!( + sweep + .iter() + .any(|report| report.timestamp == latest_b.timestamp) + ); } #[test] fn given_nan_p99_when_picking_should_prefer_finite_values() { - let mut nan_run = benchmark("2026-04-21T10:00:00Z", 0.0, 100.0); + let mut nan_run = run( + "2026-04-21T10:00:00Z", + "hw-a", + "0.7.0", + [0.0, 0.0, 0.0], + 100.0, + ); nan_run.group_metrics[0].summary.average_p99_latency_ms = f64::NAN; - let good = benchmark("2026-04-21T10:10:00Z", 3.0, 80.0); - let picked = pick_best_from_recent_batch(&[&nan_run, &good]).expect("expected a pick"); + let good = run( + "2026-04-21T10:10:00Z", + "hw-a", + "0.7.0", + [3.0, 6.0, 12.0], + 80.0, + ); + let picked = pick_best_from_recent_batch(&[&nan_run, &good]).expect("pick"); assert_eq!(picked.timestamp, good.timestamp); } - fn benchmark(timestamp: &str, p99_ms: f64, throughput_mb_s: f64) -> BenchmarkReportLight { + #[test] + fn given_shared_recent_feed_when_picking_should_match_sidebar_top_sweep() { + let recent_feed_from_api = [ + run( + "2026-04-21T13:30:00Z", + "hw-a", + "0.7.0", + [5.0, 12.0, 22.0], + 140.0, + ), + run( + "2026-04-21T13:20:00Z", + "hw-a", + "0.7.0", + [2.0, 9.0, 19.0], + 90.0, + ), + run( + "2026-04-21T13:10:00Z", + "hw-a", + "0.7.0", + [3.0, 10.0, 21.0], + 120.0, + ), + run( + "2024-02-01T10:00:00Z", + "hw-legacy", + "0.3.0", + [1.0, 4.0, 8.0], + 999.0, + ), + ]; + let refs: Vec<&BenchmarkReportLight> = recent_feed_from_api.iter().collect(); + + let sidebar_top_sweep_key = ( + recent_feed_from_api[0].hardware.identifier.clone(), + recent_feed_from_api[0].params.gitref.clone(), + ); + let sweep = latest_sweep(&refs); + for report in &sweep { + assert_eq!( + ( + report.hardware.identifier.clone(), + report.params.gitref.clone() + ), + sidebar_top_sweep_key, + "hero sweep leaked outside sidebar's newest (hardware, gitref)" + ); + } + + let picked = pick_best_from_recent_batch(&refs).expect("pick"); + assert_eq!(picked.hardware.identifier, sidebar_top_sweep_key.0); + assert_eq!(picked.params.gitref, sidebar_top_sweep_key.1); + assert_eq!(picked.timestamp, recent_feed_from_api[1].timestamp); + } + + fn run( + timestamp: &str, + hardware: &str, + gitref: &str, + latencies: [f64; 3], + throughput_mb_s: f64, + ) -> BenchmarkReportLight { + let [p99, p999, p9999] = latencies; let mut report = BenchmarkReportLight { timestamp: timestamp.to_string(), ..Default::default() }; + report.hardware.identifier = Some(hardware.to_string()); + report.params.gitref = Some(gitref.to_string()); report.group_metrics.push(BenchmarkGroupMetricsLight { - summary: summary_with(throughput_mb_s, p99_ms), + summary: summary_with(throughput_mb_s, p99, p999, p9999), latency_distribution: None, }); report } - fn summary_with(throughput_mb_s: f64, p99_ms: f64) -> BenchmarkGroupMetricsSummary { + fn summary_with( + throughput_mb_s: f64, + p99: f64, + p999: f64, + p9999: f64, + ) -> BenchmarkGroupMetricsSummary { BenchmarkGroupMetricsSummary { kind: GroupMetricsKind::Producers, total_throughput_megabytes_per_second: throughput_mb_s, @@ -413,9 +717,9 @@ mod tests { average_p50_latency_ms: 0.0, average_p90_latency_ms: 0.0, average_p95_latency_ms: 0.0, - average_p99_latency_ms: p99_ms, - average_p999_latency_ms: 0.0, - average_p9999_latency_ms: 0.0, + average_p99_latency_ms: p99, + average_p999_latency_ms: p999, + average_p9999_latency_ms: p9999, average_latency_ms: 0.0, average_median_latency_ms: 0.0, min_latency_ms: 0.0, diff --git a/core/bench/dashboard/frontend/src/state/ui.rs b/core/bench/dashboard/frontend/src/state/ui.rs index 7b4dd68de..c3b241380 100644 --- a/core/bench/dashboard/frontend/src/state/ui.rs +++ b/core/bench/dashboard/frontend/src/state/ui.rs @@ -22,14 +22,6 @@ use std::collections::HashSet; use std::rc::Rc; use yew::prelude::*; -#[derive(Clone, Debug, PartialEq)] -#[allow(dead_code)] -pub enum ViewMode { - SingleGitref, - GitrefTrend, - RecentBenchmarks, -} - #[derive(Clone, Debug, Default, PartialEq)] pub struct ParamRange { pub from: Option<u32>, @@ -201,7 +193,6 @@ impl KindGroup { #[derive(Clone, Debug, PartialEq)] pub struct UiState { - pub view_mode: ViewMode, pub selected_measurement: MeasurementType, pub is_benchmark_tooltip_visible: bool, pub is_server_stats_tooltip_visible: bool, @@ -212,12 +203,13 @@ pub struct UiState { pub sidebar_search: String, pub sidebar_sort: SidebarSort, pub sidebar_kind_filter: HashSet<KindGroup>, + pub hardware_filter: Option<String>, + pub gitref_filter: Option<String>, } impl Default for UiState { fn default() -> Self { Self { - view_mode: ViewMode::SingleGitref, selected_measurement: MeasurementType::Latency, is_benchmark_tooltip_visible: false, is_server_stats_tooltip_visible: false, @@ -228,6 +220,8 @@ impl Default for UiState { sidebar_search: String::new(), sidebar_sort: SidebarSort::default(), sidebar_kind_filter: HashSet::new(), + hardware_filter: None, + gitref_filter: None, } } } @@ -242,7 +236,6 @@ pub enum TopBarPopup { pub enum UiAction { SetMeasurementType(MeasurementType), TogglePopup(TopBarPopup), - SetViewMode(ViewMode), SetParamRange(ParamField, ParamRange), SetMetricRange(MetricField, MetricRange), ClearParamFilters, @@ -252,6 +245,8 @@ pub enum UiAction { SetSidebarSearch(String), SetSidebarSort(SidebarSort), ToggleKindFilter(KindGroup), + SetHardwareFilter(Option<String>), + SetGitrefFilter(Option<String>), } impl Reducible for UiState { @@ -285,8 +280,12 @@ impl Reducible for UiState { ..(*self).clone() } } - UiAction::SetViewMode(vm) => UiState { - view_mode: vm, + UiAction::SetHardwareFilter(value) => UiState { + hardware_filter: value, + ..(*self).clone() + }, + UiAction::SetGitrefFilter(value) => UiState { + gitref_filter: value, ..(*self).clone() }, UiAction::SetParamRange(field, range) => { diff --git a/core/bench/dashboard/frontend/src/version.rs b/core/bench/dashboard/frontend/src/version.rs new file mode 100644 index 000000000..9e452b45b --- /dev/null +++ b/core/bench/dashboard/frontend/src/version.rs @@ -0,0 +1,180 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use std::cmp::Ordering; + +/// Recency key derived from a semver-like gitref such as `0.7.0` or +/// `0.7.0-edge.1`. Larger = more recent. +/// +/// Ordering rules (differ from strict semver because suffixes in this +/// project denote post-release nightlies, not pre-release candidates): +/// - Compare `(major, minor, patch)` first. +/// - On equal base, a suffixed build (`-edge.N`, `-dev.N`, ...) ranks +/// ABOVE the plain release, because nightlies ship AFTER the tag. +/// - Two suffixed builds compare by suffix build number, then tag name. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SemverRecency { + pub major: u32, + pub minor: u32, + pub patch: u32, + pub suffix: Option<SemverSuffix>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SemverSuffix { + pub tag: String, + pub number: u32, +} + +impl Ord for SemverRecency { + fn cmp(&self, other: &Self) -> Ordering { + self.major + .cmp(&other.major) + .then(self.minor.cmp(&other.minor)) + .then(self.patch.cmp(&other.patch)) + .then(cmp_suffix(self.suffix.as_ref(), other.suffix.as_ref())) + } +} + +impl PartialOrd for SemverRecency { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + Some(self.cmp(other)) + } +} + +/// Parse a gitref into a recency key. Returns `None` for plain commit +/// hashes or anything that doesn't match `X.Y.Z` / `X.Y.Z-tag[.N]`. +pub fn parse_semver_recency(gitref: &str) -> Option<SemverRecency> { + let raw = gitref.trim().trim_start_matches('v'); + let (base, suffix_str) = match raw.split_once('-') { + Some((base, rest)) => (base, Some(rest)), + None => (raw, None), + }; + let mut parts = base.split('.'); + let major = parts.next()?.parse::<u32>().ok()?; + let minor = parts.next()?.parse::<u32>().ok()?; + let patch = parts.next()?.parse::<u32>().ok()?; + if parts.next().is_some() { + return None; + } + let suffix = suffix_str.map(parse_suffix); + Some(SemverRecency { + major, + minor, + patch, + suffix, + }) +} + +fn parse_suffix(raw: &str) -> SemverSuffix { + match raw.rsplit_once('.') { + Some((tag, number)) if !tag.is_empty() => SemverSuffix { + tag: tag.to_string(), + number: number.parse().unwrap_or(0), + }, + _ => SemverSuffix { + tag: raw.to_string(), + number: 0, + }, + } +} + +fn cmp_suffix(left: Option<&SemverSuffix>, right: Option<&SemverSuffix>) -> Ordering { + match (left, right) { + (None, None) => Ordering::Equal, + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (Some(l), Some(r)) => l.number.cmp(&r.number).then_with(|| l.tag.cmp(&r.tag)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_plain_commit_hash_when_parsing_should_return_none() { + assert!(parse_semver_recency("abc123").is_none()); + assert!(parse_semver_recency("main").is_none()); + assert!(parse_semver_recency("0.7").is_none()); + assert!(parse_semver_recency("0.7.0.1").is_none()); + } + + #[test] + fn given_plain_semver_when_parsing_should_yield_base_only() { + let key = parse_semver_recency("0.7.0").expect("parse"); + assert_eq!(key.major, 0); + assert_eq!(key.minor, 7); + assert_eq!(key.patch, 0); + assert!(key.suffix.is_none()); + } + + #[test] + fn given_suffixed_version_when_parsing_should_capture_tag_and_number() { + let key = parse_semver_recency("0.7.0-edge.1").expect("parse"); + let suffix = key.suffix.as_ref().expect("suffix"); + assert_eq!(suffix.tag, "edge"); + assert_eq!(suffix.number, 1); + } + + #[test] + fn given_leading_v_when_parsing_should_still_match() { + let key = parse_semver_recency("v0.7.0-dev.4").expect("parse"); + assert_eq!(key.major, 0); + assert_eq!(key.suffix.as_ref().expect("suffix").tag.as_str(), "dev"); + } + + #[test] + fn given_suffixed_and_plain_same_base_when_comparing_should_rank_suffixed_higher() { + let edge = parse_semver_recency("0.7.0-edge.1").expect("parse"); + let plain = parse_semver_recency("0.7.0").expect("parse"); + assert!(edge > plain); + } + + #[test] + fn given_bigger_base_when_comparing_should_win_regardless_of_suffix() { + let plain_newer = parse_semver_recency("0.7.0").expect("parse"); + let edge_older = parse_semver_recency("0.6.9-dev.4").expect("parse"); + assert!(plain_newer > edge_older); + } + + #[test] + fn given_same_suffix_tag_different_numbers_when_comparing_should_prefer_higher_number() { + let first = parse_semver_recency("0.7.0-edge.1").expect("parse"); + let fifth = parse_semver_recency("0.7.0-edge.5").expect("parse"); + assert!(fifth > first); + } + + #[test] + fn given_user_example_sequence_when_sorting_should_order_newest_first() { + let mut entries = [ + parse_semver_recency("0.6.9-dev.4").expect("parse"), + parse_semver_recency("0.7.0-edge.1").expect("parse"), + parse_semver_recency("0.7.0").expect("parse"), + ]; + entries.sort_by(|left, right| right.cmp(left)); + assert_eq!( + entries[0], + parse_semver_recency("0.7.0-edge.1").expect("parse") + ); + assert_eq!(entries[1], parse_semver_recency("0.7.0").expect("parse")); + assert_eq!( + entries[2], + parse_semver_recency("0.6.9-dev.4").expect("parse") + ); + } +} diff --git a/core/bench/report/src/plotting/chart.rs b/core/bench/report/src/plotting/chart.rs index 5a2e02d67..b56b1bd04 100644 --- a/core/bench/report/src/plotting/chart.rs +++ b/core/bench/report/src/plotting/chart.rs @@ -77,13 +77,7 @@ impl IggyChart { .item_height(14) .type_(LegendType::Scroll), ) - .grid( - Grid::new() - .left("5%") - .right("20%") - .top(grid_top) - .bottom("8%"), - ) + .grid(Grid::new().left(70).right("20%").top(grid_top).bottom("8%")) .data_zoom( DataZoom::new() .show(true) @@ -145,9 +139,10 @@ impl IggyChart { Axis::new() .type_(AxisType::Value) .name(axis_label) - .name_location(NameLocation::End) + .name_location(NameLocation::Middle) + .name_rotation(90) .name_text_style(TextStyle::new().font_size(AXIS_TEXT_SIZE)) - .name_gap(15) + .name_gap(44) .position("left") .axis_label(AxisLabel::new()) .split_line(SplitLine::new().show(true)), @@ -162,8 +157,9 @@ impl IggyChart { Axis::new() .type_(AxisType::Value) .name(y1_label) - .name_location(NameLocation::End) - .name_gap(15) + .name_location(NameLocation::Middle) + .name_rotation(90) + .name_gap(44) .name_text_style(TextStyle::new().font_size(AXIS_TEXT_SIZE)) .position("left") .axis_label(AxisLabel::new()) @@ -174,9 +170,10 @@ impl IggyChart { Axis::new() .type_(AxisType::Value) .name(y2_label) - .name_location(NameLocation::End) + .name_location(NameLocation::Middle) + .name_rotation(-90) .name_text_style(TextStyle::new().font_size(AXIS_TEXT_SIZE)) - .name_gap(15) + .name_gap(54) .position("right") .axis_label(AxisLabel::new()) .split_line(SplitLine::new().show(true)),
