diff --git a/assets/benchmarking_platform.png b/assets/benchmarking_platform.png index dfc9e8c713..de8d1a74a8 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 2e87bfc800..077bbd14f4 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 5ecb741aa4..e3dc1a3c6f 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 { )}> if show_detail { - + } else { diff --git a/core/bench/dashboard/frontend/src/components/footer.rs b/core/bench/dashboard/frontend/src/components/footer.rs index 8bd5d2d959..58cfd6f0f8 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 { diff --git a/core/bench/dashboard/frontend/src/components/layout/hero.rs b/core/bench/dashboard/frontend/src/components/layout/hero.rs index a558f73f3c..a5905d87a9 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::::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; + } + let timeout = Timeout::new(2_000, move || is_slow.set(true)); + Box::new(move || drop(timeout)) as Box + }); + } + + 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! { -
- { render_background_grid() } -
-
{"Apache Iggy"}
-

{"Benchmarks"}

-

- {"Pick a hardware and gitref in the sidebar to view performance data."} -

-
-
- }; + 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 {
{ render_background_grid() }
- { 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>, -) -> Option { - entries - .values() - .flatten() - .max_by(|left, right| left.timestamp.cmp(&right.timestamp)) - .map(|benchmark| benchmark.uuid.to_string()) -} - async fn fetch_latest_uuid() -> Option { 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, ) -> 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! {
@@ -296,7 +277,9 @@ fn render_headline( {value} {unit} -

{sub}

+

+ { render_hero_sub(&subject, hardware, gitref) } +

{"Modern hardware is incredibly capable. "} {"Apache Iggy was built for it."} @@ -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 { + {prefix} + } + if let Some(gitref) = gitref_owned { + if has_prefix { + {" @ "} + } + + {gitref} + + + + + + + } + + } +} + +fn iggy_gitref_url(gitref: &str) -> String { + format!("https://github.com/apache/iggy/tree/{gitref}") +} + fn render_stat_cards(stats: &HeroStats) -> Html { html! {

@@ -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! { +
+ { render_background_grid() } +
+ +
{"Apache Iggy"}
+
{"Benchmarks"}
+ if is_slow { +

+ {"Fetching the latest benchmark run. This can take a moment on a cold cache."} +

+ } + {"Loading benchmarks"} +
+
+ } +} 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 766b53e9f2..6b9a796d82 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()) }
- { benchmark.identifier_with_cpu_and_version() } + { render_benchmark_identifier(benchmark) }
@@ -215,7 +213,7 @@ fn render_compare_pane( { benchmark.title(&measurement.to_string()) }
- { benchmark.identifier_with_cpu_and_version() } + { render_benchmark_identifier(benchmark) }
@@ -226,27 +224,52 @@ fn render_compare_pane( } } -fn render_empty_recent() -> Html { +fn render_loading() -> Html { html! {
-

{"Select a recent benchmark"}

-

{"Choose a benchmark from the sidebar to display performance data."}

+

{"Loading benchmark..."}

} } -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! { -
-
-
-

{"Loading benchmark..."}

-
-
-
+ <> + {format!("{hardware} @ {cpu}")} + if let Some(gitref) = gitref { + {"·"} + + {gitref} + + + + + + + } + } } diff --git a/core/bench/dashboard/frontend/src/components/layout/sidebar.rs b/core/bench/dashboard/frontend/src/components/layout/sidebar.rs index c71dd7457d..72aba0a596 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, - pub on_hardware_select: Callback, -} +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::(); + + let benchmarks = use_state(Vec::::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! { } @@ -247,3 +324,51 @@ fn render_compare_hint(ui: &yew::UseReducerHandle) -> fn short_name(full: &str) -> String { full.split('(').next().unwrap_or(full).trim().to_string() } + +fn collect_hardware(benchmarks: &[BenchmarkReportLight]) -> Vec { + let mut set: BTreeSet = 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 { + let mut newest: std::collections::HashMap = + 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 f5893097f2..6b1f28e1e6 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 { } if props.show_detail_actions && selected_benchmark.is_some() { +
+ + if *share_copied { + {"Link copied"} + } +