You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by pi...@apache.org on 2026/04/20 20:46:06 UTC
(iggy) 01/01: feat(bench): improve UI, add new mode and landing page
This is an automated email from the ASF dual-hosted git repository.
spetz pushed a commit to branch bench_ui_refactor
in repository https://gitbox.apache.org/repos/asf/iggy.git
commit 05273f2ab814b186fc81bd3ae20e269b1ed0e5be
Author: spetz <pi...@gmail.com>
AuthorDate: Mon Apr 20 22:45:52 2026 +0200
feat(bench): improve UI, add new mode and landing page
---
core/bench/dashboard/frontend/assets/style.css | 1104 +++++++++++++++++++-
.../frontend/src/components/app_content.rs | 21 +-
.../dashboard/frontend/src/components/chart/mod.rs | 1 +
.../frontend/src/components/chart/single_chart.rs | 2 +-
.../frontend/src/components/chart/tail_chart.rs | 420 ++++++++
.../frontend/src/components/chart/trend_chart.rs | 6 +-
.../frontend/src/components/embed_modal.rs | 1 +
.../frontend/src/components/layout/hero.rs | 391 +++++++
.../frontend/src/components/layout/logo.rs | 35 +-
.../frontend/src/components/layout/main_content.rs | 220 +++-
.../frontend/src/components/layout/mod.rs | 5 +
.../src/components/layout/percentile_card.rs | 93 ++
.../frontend/src/components/layout/sidebar.rs | 16 +-
.../src/components/layout/sidebar_toggle.rs | 57 +
.../frontend/src/components/layout/sweep_view.rs | 290 +++++
.../src/components/layout/theme_toggle_floating.rs | 66 ++
.../frontend/src/components/layout/topbar.rs | 14 +-
.../src/components/selectors/benchmark_selector.rs | 249 +++--
.../selectors/measurement_type_selector.rs | 9 +
.../frontend/src/components/selectors/mod.rs | 1 +
.../components/selectors/param_filters_panel.rs | 211 ++++
.../selectors/recent_benchmarks_selector.rs | 78 +-
.../dashboard/frontend/src/components/theme/mod.rs | 1 -
.../frontend/src/components/theme/theme_toggle.rs | 57 -
.../dashboard/frontend/src/state/benchmark.rs | 74 +-
core/bench/dashboard/frontend/src/state/ui.rs | 165 ++-
scripts/dashboard/run_dev.sh | 13 +-
27 files changed, 3334 insertions(+), 266 deletions(-)
diff --git a/core/bench/dashboard/frontend/assets/style.css b/core/bench/dashboard/frontend/assets/style.css
index 8a82cce6c..c4368b939 100644
--- a/core/bench/dashboard/frontend/assets/style.css
+++ b/core/bench/dashboard/frontend/assets/style.css
@@ -331,6 +331,21 @@ body.dark select option {
padding: 0 var(--spacing-md);
}
+.logo-home-button {
+ display: block;
+ width: 100%;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: inherit;
+ font: inherit;
+ transition: opacity var(--transition-speed);
+}
+
+.logo-home-button:hover {
+ opacity: 0.8;
+}
+
.logo img {
max-width: 200px;
height: auto;
@@ -349,8 +364,9 @@ body.dark select option {
.top-buttons {
display: flex;
align-items: center;
+ justify-content: flex-end;
gap: var(--spacing-sm);
- padding: var(--spacing-sm);
+ padding: var(--spacing-sm) 60px var(--spacing-sm) 60px;
background-color: var(--color-background);
z-index: 20;
}
@@ -1876,3 +1892,1089 @@ body.dark .benchmark-list-item-metric.message-throughput {
color: #fff;
border-color: #777;
}
+
+/* Param filters panel (producers / consumers / streams / partitions / consumer groups) */
+.param-filters-container {
+ margin: var(--spacing-md) 0;
+ width: 100%;
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius);
+ background-color: var(--color-background);
+ overflow: hidden;
+}
+
+.param-filters-header {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: var(--font-size-sm);
+ color: var(--color-text);
+ text-align: left;
+ transition: background-color var(--transition-speed);
+}
+
+.param-filters-header:hover {
+ background-color: var(--color-active);
+}
+
+.param-filters-chevron {
+ display: inline-block;
+ width: 1em;
+ color: var(--color-text-secondary);
+ font-family: monospace;
+}
+
+.param-filters-title {
+ font-weight: 500;
+ flex: 1;
+}
+
+.param-filters-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 20px;
+ height: 20px;
+ padding: 0 6px;
+ border-radius: 10px;
+ background-color: var(--color-dark-active, #0d6efd);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.param-filters-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs, 4px);
+ padding: var(--spacing-sm) var(--spacing-md) var(--spacing-md);
+ border-top: 1px solid var(--color-border);
+}
+
+.param-filter-row {
+ display: grid;
+ grid-template-columns: 110px 1fr auto 1fr;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.param-filter-label {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+}
+
+.param-filter-input {
+ width: 100%;
+ padding: 4px 8px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius);
+ font-size: var(--font-size-sm);
+ box-sizing: border-box;
+ background-color: var(--color-background);
+ color: var(--color-text);
+}
+
+.param-filter-input:focus {
+ outline: none;
+ border-color: var(--color-active);
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
+}
+
+.param-filter-sep {
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+ user-select: none;
+}
+
+.param-filters-clear {
+ margin-top: var(--spacing-xs, 4px);
+ padding: 4px 8px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius);
+ background: transparent;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+ cursor: pointer;
+ align-self: flex-end;
+ transition: all var(--transition-speed);
+}
+
+.param-filters-clear:hover {
+ color: var(--color-text);
+ border-color: var(--color-active);
+}
+
+body.dark .param-filter-input {
+ background-color: var(--color-dark-input);
+ border-color: var(--color-dark-border);
+ color: var(--color-dark-text);
+}
+
+body.dark .param-filter-input:focus {
+ border-color: var(--color-dark-active);
+ box-shadow: 0 0 0 2px rgba(var(--color-dark-active-rgb), 0.4);
+}
+
+body.dark .param-filters-container {
+ border-color: var(--color-dark-border);
+ background-color: var(--color-dark-sidebar, transparent);
+}
+
+body.dark .param-filters-body {
+ border-top-color: var(--color-dark-border);
+}
+
+body.dark .param-filters-header:hover {
+ background-color: var(--color-dark-active-bg, rgba(255, 255, 255, 0.05));
+}
+
+/* Sidebar collapse */
+.sidebar {
+ transition: width var(--transition-speed) ease, border-right-color var(--transition-speed) ease;
+}
+
+.container.sidebar-collapsed .sidebar {
+ width: 0;
+ min-width: 0;
+ border-right-color: transparent;
+ padding: 0;
+ pointer-events: none;
+}
+
+.container.sidebar-collapsed .sidebar > * {
+ opacity: 0;
+ transition: opacity calc(var(--transition-speed) / 2) ease;
+}
+
+.sidebar-toggle-floating {
+ position: fixed;
+ top: 14px;
+ left: 486px;
+ z-index: 50;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ background: var(--color-background);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ cursor: pointer;
+ color: var(--color-text-secondary);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ transition: left var(--transition-speed) ease,
+ color var(--transition-speed),
+ background-color var(--transition-speed),
+ border-color var(--transition-speed);
+}
+
+.sidebar-toggle-floating:hover {
+ color: var(--color-text);
+ border-color: var(--color-active, #ff9103);
+ background-color: var(--color-background);
+}
+
+.container.sidebar-collapsed .sidebar-toggle-floating {
+ left: 14px;
+ color: #ff9103;
+ border-color: rgba(255, 145, 3, 0.5);
+}
+
+body.dark .sidebar-toggle-floating {
+ background: var(--color-dark-sidebar);
+ border-color: var(--color-dark-border);
+ color: var(--color-dark-text-secondary);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+}
+
+body.dark .sidebar-toggle-floating:hover {
+ color: var(--color-dark-text);
+}
+
+.theme-toggle-floating {
+ position: fixed;
+ top: 14px;
+ right: 14px;
+ z-index: 50;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ background: var(--color-background);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ cursor: pointer;
+ color: var(--color-text-secondary);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ transition: color var(--transition-speed),
+ border-color var(--transition-speed),
+ transform var(--transition-speed);
+}
+
+.theme-toggle-floating:hover {
+ color: #ff9103;
+ border-color: rgba(255, 145, 3, 0.5);
+ transform: rotate(15deg);
+}
+
+body.dark .theme-toggle-floating {
+ background: var(--color-dark-sidebar);
+ border-color: var(--color-dark-border);
+ color: var(--color-dark-text-secondary);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+}
+
+/* Hero landing v2 - Iggy brand aesthetic. Orange #ff9103 accent, mono numerics,
+ clean dark/light surfaces with subtle dot-grid bg and radial glow. */
+.hero-v2 {
+ --hero-accent: #ff9103;
+ --hero-accent-soft: rgba(255, 145, 3, 0.14);
+ --hero-accent-dim: rgba(255, 145, 3, 0.4);
+ --hero-surface: rgba(255, 255, 255, 0.75);
+ --hero-border: rgba(0, 0, 0, 0.08);
+ --hero-grid-line: rgba(0, 0, 0, 0.05);
+ --hero-muted: #6a7280;
+ --hero-text: #0b1220;
+ --hero-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ flex: 1;
+ min-height: 0;
+ padding: clamp(28px, 4vw, 64px) clamp(20px, 4vw, 72px);
+ overflow: auto;
+ color: var(--hero-text);
+}
+
+body.dark .hero-v2 {
+ --hero-surface: rgba(255, 255, 255, 0.03);
+ --hero-border: rgba(255, 255, 255, 0.06);
+ --hero-grid-line: rgba(255, 255, 255, 0.05);
+ --hero-muted: #9ca3af;
+ --hero-text: #f5f5f5;
+}
+
+.hero-v2-inner {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ gap: clamp(24px, 3vw, 48px);
+ width: 100%;
+ max-width: 1320px;
+ margin: 0 auto;
+}
+
+.hero-v2-bg {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ overflow: hidden;
+ z-index: 0;
+}
+
+.hero-v2-bg-dot-grid {
+ position: absolute;
+ inset: 0;
+ background-image: radial-gradient(var(--hero-grid-line) 1px, transparent 1px);
+ background-size: 22px 22px;
+ mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black 40%, transparent 100%);
+ -webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black 40%, transparent 100%);
+}
+
+.hero-v2-bg-glow {
+ position: absolute;
+ border-radius: 50%;
+ filter: blur(80px);
+ opacity: 0.55;
+}
+
+.hero-v2-bg-glow-primary {
+ top: -10%;
+ left: -10%;
+ width: 60%;
+ height: 60%;
+ background: radial-gradient(circle, rgba(255, 145, 3, 0.22), transparent 70%);
+}
+
+.hero-v2-bg-glow-secondary {
+ bottom: -20%;
+ right: -10%;
+ width: 55%;
+ height: 55%;
+ background: radial-gradient(circle, rgba(56, 189, 248, 0.14), transparent 70%);
+}
+
+.hero-v2-empty {
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+}
+
+.hero-v2-empty-inner {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ max-width: 520px;
+}
+
+.hero-v2-empty-title {
+ margin: 0;
+ font-size: clamp(44px, 7vw, 96px);
+ font-weight: 800;
+ letter-spacing: -0.04em;
+ line-height: 1;
+ color: var(--hero-text);
+}
+
+.hero-v2-empty-sub {
+ margin: 0;
+ color: var(--hero-muted);
+ font-size: var(--font-size-md);
+ line-height: 1.5;
+}
+
+.hero-v2-headline {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ animation: hero-fade-up 700ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
+}
+
+.hero-v2-eyebrow {
+ font-family: var(--hero-mono);
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.18em;
+ color: var(--hero-accent);
+ font-weight: 600;
+}
+
+.hero-v2-title {
+ display: flex;
+ align-items: baseline;
+ gap: 16px;
+ margin: 0;
+ line-height: 0.95;
+ letter-spacing: -0.04em;
+ font-weight: 800;
+ color: var(--hero-text);
+}
+
+.hero-v2-big {
+ font-size: clamp(64px, 10vw, 160px);
+ font-variant-numeric: tabular-nums;
+ background: linear-gradient(180deg, var(--hero-text) 0%, var(--hero-text) 55%, var(--hero-accent) 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ color: transparent;
+}
+
+.hero-v2-unit {
+ font-family: var(--hero-mono);
+ font-size: clamp(20px, 2.4vw, 36px);
+ color: var(--hero-accent);
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.hero-v2-sub {
+ margin: 0;
+ color: var(--hero-muted);
+ font-family: var(--hero-mono);
+ font-size: 13px;
+ letter-spacing: 0.02em;
+}
+
+.hero-v2-tagline {
+ margin: 18px 0 0;
+ font-size: clamp(18px, 1.7vw, 24px);
+ font-weight: 500;
+ line-height: 1.35;
+ color: var(--hero-text);
+ letter-spacing: -0.015em;
+ max-width: 720px;
+}
+
+.hero-v2-tagline-accent {
+ background: linear-gradient(90deg, var(--hero-accent) 0%, #ffb347 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ color: transparent;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+
+.hero-v2-cards {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 16px;
+}
+
+@media (max-width: 920px) {
+ .hero-v2-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+.hero-v2-card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 20px 22px;
+ border: 1px solid var(--hero-border);
+ border-radius: 14px;
+ background: var(--hero-surface);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1),
+ border-color 300ms ease;
+ animation: hero-fade-up 600ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
+ animation-delay: calc(200ms + var(--stagger, 0) * 80ms);
+ position: relative;
+ overflow: hidden;
+}
+
+.hero-v2-card::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, var(--hero-accent-dim), transparent);
+ opacity: 0;
+ transition: opacity 300ms ease;
+}
+
+.hero-v2-card:hover {
+ transform: translateY(-2px);
+ border-color: var(--hero-accent-dim);
+}
+
+.hero-v2-card:hover::before {
+ opacity: 1;
+}
+
+.hero-v2-card-value-row {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+}
+
+.hero-v2-card-value {
+ font-family: var(--hero-mono);
+ font-size: 32px;
+ font-weight: 700;
+ color: var(--hero-text);
+ line-height: 1;
+ letter-spacing: -0.02em;
+}
+
+.hero-v2-card-unit {
+ font-family: var(--hero-mono);
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--hero-muted);
+}
+
+.hero-v2-card-label {
+ font-size: 12px;
+ color: var(--hero-muted);
+ font-weight: 500;
+}
+
+.hero-v2-card-sub {
+ font-family: var(--hero-mono);
+ font-size: 11px;
+ color: var(--hero-muted);
+ opacity: 0.8;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.hero-v2-card-accent {
+ border-color: var(--hero-accent-dim);
+ background: linear-gradient(180deg, var(--hero-accent-soft), var(--hero-surface));
+}
+
+.hero-v2-card-accent .hero-v2-card-value { color: var(--hero-accent); }
+
+.hero-v2-card-dim { opacity: 0.55; }
+
+/* Tail latency chart - reusable component.
+ Used standalone in Single view (Tail measurement) and inside Hero. */
+.tail-chart-wrap {
+ --tail-accent: #ff9103;
+ --tail-accent-dim: rgba(255, 145, 3, 0.4);
+ --tail-border: var(--color-border);
+ --tail-surface: var(--color-background);
+ --tail-muted: var(--color-text-secondary);
+ --tail-grid: rgba(0, 0, 0, 0.06);
+ --tail-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--tail-border);
+ border-radius: 16px;
+ background: var(--tail-surface);
+ overflow: hidden;
+ animation: hero-fade-up 800ms cubic-bezier(0.2, 0.8, 0.2, 1) 300ms both;
+}
+
+body.dark .tail-chart-wrap {
+ --tail-border: rgba(255, 255, 255, 0.08);
+ --tail-surface: rgba(255, 255, 255, 0.03);
+ --tail-grid: rgba(255, 255, 255, 0.05);
+ --tail-muted: #9ca3af;
+}
+
+.hero-v2 .tail-chart-wrap {
+ --tail-border: var(--hero-border);
+ --tail-surface: var(--hero-surface);
+ --tail-muted: var(--hero-muted);
+ --tail-grid: var(--hero-grid-line);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+}
+
+.tail-chart-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 20px;
+ border-bottom: 1px solid var(--tail-border);
+ gap: var(--spacing-md);
+}
+
+.tail-chart-legend {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.tail-chart-legend-dot {
+ display: inline-block;
+ width: 18px;
+ height: 3px;
+ border-radius: 2px;
+ background-color: var(--tail-accent);
+}
+
+.tail-chart-legend-label {
+ font-family: var(--tail-mono);
+ font-size: 12px;
+ color: var(--tail-muted);
+}
+
+.tail-chart-header-right {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ min-width: 0;
+}
+
+.tail-chart-subject {
+ font-family: var(--tail-mono);
+ font-size: 12px;
+ color: var(--tail-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 320px;
+}
+
+.tail-chart-details-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border: 1px solid var(--tail-accent-dim);
+ border-radius: 8px;
+ background: transparent;
+ color: var(--tail-accent);
+ font-family: var(--tail-mono);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 200ms ease;
+ white-space: nowrap;
+}
+
+.tail-chart-details-btn:hover {
+ background: var(--tail-accent);
+ color: #0b1220;
+ border-color: var(--tail-accent);
+ transform: translateX(2px);
+}
+
+.tail-chart {
+ display: block;
+ width: 100%;
+ height: auto;
+ aspect-ratio: 1000 / 340;
+ max-height: 480px;
+ padding: 12px 8px 4px;
+}
+
+.tail-chart-wrap-compact .tail-chart {
+ max-height: 340px;
+}
+
+.tail-chart-line {
+ stroke-dasharray: 2400;
+ stroke-dashoffset: 2400;
+ animation: hero-tail-draw 1.8s cubic-bezier(0.25, 0.1, 0.25, 1) forwards 200ms;
+}
+
+.tail-chart-area { animation: hero-fade-in 800ms ease both 1.4s; }
+
+.tail-chart-point-dot {
+ fill: var(--tail-accent);
+ stroke: var(--tail-surface);
+ stroke-width: 2;
+ transform-origin: center;
+ animation: hero-point-pop 400ms cubic-bezier(0.2, 1.4, 0.3, 1) both;
+ animation-delay: calc(1.5s + var(--stagger, 0) * 100ms);
+ transition: r 150ms ease;
+ cursor: pointer;
+}
+
+.tail-chart-point-halo {
+ animation: tail-halo-pulse 1200ms ease-in-out infinite;
+ transform-origin: center;
+}
+
+.tail-chart-point.active .tail-chart-point-dot {
+ fill: #fff;
+ stroke: var(--tail-accent);
+ stroke-width: 3;
+}
+
+.tail-chart-crosshair line {
+ stroke: var(--tail-accent);
+ stroke-width: 1;
+ stroke-dasharray: 3 4;
+ stroke-opacity: 0.5;
+ pointer-events: none;
+}
+
+.tail-chart-tooltip {
+ animation: hero-fade-in 120ms ease forwards;
+}
+
+.tail-chart-tooltip-bg {
+ fill: var(--tail-surface);
+ stroke: var(--tail-accent);
+ stroke-opacity: 0.5;
+ stroke-width: 1;
+ filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.25));
+}
+
+body.dark .tail-chart-tooltip-bg {
+ fill: #0b1220;
+ stroke-opacity: 0.7;
+}
+
+.tail-chart-tooltip-label {
+ fill: var(--tail-accent);
+ font-family: var(--tail-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.tail-chart-tooltip-value {
+ fill: var(--color-text);
+ font-family: var(--tail-mono);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.tail-chart-axis-label-active {
+ fill: var(--tail-accent) !important;
+ font-weight: 700 !important;
+}
+
+.tail-chart-legend-hint {
+ font-family: var(--tail-mono);
+ font-size: 10px;
+ color: var(--tail-muted);
+ opacity: 0.6;
+ margin-left: 8px;
+}
+
+@keyframes tail-halo-pulse {
+ 0%, 100% { opacity: 0.5; r: 9; }
+ 50% { opacity: 0.2; r: 14; }
+}
+
+.tail-chart-grid line { stroke: var(--tail-grid); stroke-width: 1; }
+
+.tail-chart-grid text {
+ fill: var(--tail-muted);
+ font-family: var(--tail-mono);
+ font-size: 9px;
+}
+
+.tail-chart-axis-labels text {
+ fill: var(--tail-muted);
+ font-family: var(--tail-mono);
+ font-size: 10px;
+ font-weight: 500;
+}
+
+@keyframes hero-tail-draw {
+ from { stroke-dashoffset: 2400; }
+ to { stroke-dashoffset: 0; }
+}
+
+@keyframes hero-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes hero-fade-up {
+ from { opacity: 0; transform: translateY(16px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes hero-point-pop {
+ from { opacity: 0; transform: scale(0); }
+ to { opacity: 1; transform: scale(1); }
+}
+
+/* Pin for compare button on benchmark list items */
+.benchmark-list-item-title-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-sm);
+}
+
+.compare-pin-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 4px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: var(--border-radius);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition: all var(--transition-speed);
+ flex-shrink: 0;
+}
+
+.compare-pin-btn:hover {
+ color: var(--color-text);
+ border-color: var(--color-border);
+ background-color: var(--color-background);
+}
+
+.compare-pin-btn.active {
+ color: var(--color-active, #0d6efd);
+ border-color: var(--color-active, #0d6efd);
+}
+
+.benchmark-list-item.pinned {
+ border-left: 3px solid var(--color-active, #0d6efd);
+}
+
+body.dark .compare-pin-btn:hover {
+ background-color: var(--color-dark-input);
+}
+
+/* Compare mode (side-by-side) */
+.compare-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md);
+ min-height: 0;
+ overflow: auto;
+}
+
+.compare-banner {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: linear-gradient(90deg, rgba(13, 110, 253, 0.08), rgba(102, 16, 242, 0.08));
+ border-bottom: 1px solid var(--color-border);
+}
+
+.compare-banner-label {
+ font-weight: 600;
+ color: var(--color-text);
+ flex: 1;
+}
+
+.compare-banner-unpin {
+ padding: 4px 12px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius);
+ background: transparent;
+ color: var(--color-text);
+ cursor: pointer;
+ font-size: var(--font-size-sm);
+ transition: all var(--transition-speed);
+}
+
+.compare-banner-unpin:hover {
+ border-color: var(--color-active);
+ color: var(--color-active);
+}
+
+.compare-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--spacing-md);
+ padding: 0 var(--spacing-md) var(--spacing-md);
+ min-height: 0;
+ flex: 1;
+}
+
+.compare-pane {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ min-width: 0;
+ min-height: 0;
+ border: 1px solid var(--color-border);
+ border-radius: 12px;
+ padding: var(--spacing-md);
+ background: var(--color-background);
+}
+
+.compare-pane-label {
+ font-size: var(--font-size-sm);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--color-text-secondary);
+ font-weight: 600;
+}
+
+body.dark .compare-pane {
+ background: var(--color-dark-sidebar, #1a2234);
+ border-color: var(--color-dark-border);
+}
+
+/* Percentile strip (P50 / P95 / P99 / P99.9 / P99.99) above the main chart.
+ Sized to content so it doesn't span the full content-wrapper width. */
+.percentile-strip {
+ display: inline-flex;
+ align-items: center;
+ gap: 20px;
+ align-self: center;
+ box-sizing: border-box;
+ margin: 6px auto 12px;
+ padding: 10px 18px;
+ border: 1px solid var(--color-border);
+ border-radius: 10px;
+ background: var(--color-background);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.percentile-strip-label {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 2px;
+ padding-right: 16px;
+ border-right: 1px solid var(--color-border);
+}
+
+.percentile-strip-title {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.14em;
+ color: var(--color-text-secondary);
+ font-weight: 600;
+}
+
+.percentile-strip-unit {
+ font-size: 11px;
+ color: var(--color-text-secondary);
+ opacity: 0.6;
+}
+
+.percentile-strip-chips {
+ display: flex;
+ align-items: center;
+ gap: 22px;
+}
+
+.percentile-chip {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 90px;
+}
+
+.percentile-chip-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.percentile-chip-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ letter-spacing: 0.06em;
+}
+
+.percentile-chip-value {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--color-text);
+ font-variant-numeric: tabular-nums;
+}
+
+.percentile-chip-bar {
+ height: 4px;
+ background-color: var(--color-border);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.percentile-chip-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #ff9103, #ffb347);
+ border-radius: 2px;
+ transition: width var(--transition-speed);
+}
+
+@media (max-width: 720px) {
+ .percentile-strip-chips { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+
+body.dark .percentile-strip {
+ background: rgba(255, 255, 255, 0.03);
+ border-color: var(--color-dark-border);
+}
+
+body.dark .percentile-strip-label {
+ border-right-color: var(--color-dark-border);
+}
+
+body.dark .percentile-chip-bar {
+ background-color: var(--color-dark-border);
+}
+
+/* Sweep view (throughput + p99 vs one varying axis) */
+.sweep-view {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-md) var(--spacing-lg);
+ border: 1px solid var(--color-border);
+ border-radius: 12px;
+ background: var(--color-background);
+ margin: var(--spacing-md);
+}
+
+.sweep-view-title {
+ display: flex;
+ align-items: baseline;
+ gap: var(--spacing-sm);
+ font-weight: 600;
+ color: var(--color-text);
+}
+
+.sweep-axis {
+ color: var(--color-active, #0d6efd);
+ font-family: monospace;
+}
+
+.sweep-view-hint {
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+ font-weight: 400;
+ margin-left: auto;
+}
+
+.sweep-chart {
+ width: 100%;
+ height: 260px;
+}
+
+.sweep-grid line {
+ stroke: var(--color-border);
+ stroke-width: 1;
+}
+
+.sweep-series-tput {
+ color: #0d6efd;
+}
+
+.sweep-series-p99 {
+ color: #d63384;
+}
+
+.sweep-labels text {
+ fill: var(--color-text-secondary);
+ font-size: 11px;
+ font-family: inherit;
+}
+
+.sweep-axis-label {
+ font-style: italic;
+}
+
+.sweep-y-tput {
+ fill: #0d6efd;
+}
+
+.sweep-y-p99 {
+ fill: #d63384;
+}
+
+.sweep-legend {
+ display: flex;
+ gap: var(--spacing-lg);
+ font-size: var(--font-size-sm);
+}
+
+.sweep-legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.sweep-legend-item::before {
+ content: "";
+ display: inline-block;
+ width: 14px;
+ height: 2px;
+}
+
+.sweep-legend-tput::before {
+ background-color: #0d6efd;
+}
+
+.sweep-legend-p99::before {
+ background-color: #d63384;
+ border-top: 1px dashed #d63384;
+ background: none;
+ height: 0;
+}
+
+body.dark .sweep-view {
+ background: var(--color-dark-sidebar, #1a2234);
+ border-color: var(--color-dark-border);
+}
diff --git a/core/bench/dashboard/frontend/src/components/app_content.rs b/core/bench/dashboard/frontend/src/components/app_content.rs
index 3cc9ba070..04ea6a8b0 100644
--- a/core/bench/dashboard/frontend/src/components/app_content.rs
+++ b/core/bench/dashboard/frontend/src/components/app_content.rs
@@ -17,7 +17,10 @@
use crate::{
api,
- components::layout::{main_content::MainContent, sidebar::Sidebar},
+ components::layout::{
+ main_content::MainContent, sidebar::Sidebar, sidebar_toggle::SidebarToggle,
+ theme_toggle_floating::ThemeToggleFloating,
+ },
router::AppRoute,
state::{
benchmark::{BenchmarkAction, BenchmarkContext, use_benchmark},
@@ -82,14 +85,12 @@ fn use_init_hardware(
}
fn preferred_hardware_identifier(hw_list: &[BenchmarkHardware]) -> Option<String> {
- hw_list
+ const PREFERRED: &str = "spetz-amd-rkyv";
+ let preferred = hw_list
.iter()
- .filter_map(|hw| {
- hw.identifier
- .clone()
- .filter(|identifier| identifier == "spetz-amd-rkyv")
- })
- .next()
+ .filter_map(|hw| hw.identifier.clone())
+ .find(|identifier| identifier == PREFERRED);
+ preferred.or_else(|| hw_list.iter().find_map(|hw| hw.identifier.clone()))
}
#[hook]
@@ -342,7 +343,9 @@ pub fn app_content() -> Html {
);
html! {
- <div class="container">
+ <div class={classes!("container", ui_state.is_sidebar_collapsed.then_some("sidebar-collapsed"))}>
+ <SidebarToggle />
+ <ThemeToggleFloating />
<Sidebar
on_gitref_select={Callback::from(move |gitref: String| {
// When gitref is selected, we might also want to navigate to Home with new query params
diff --git a/core/bench/dashboard/frontend/src/components/chart/mod.rs b/core/bench/dashboard/frontend/src/components/chart/mod.rs
index 2ad178971..8a1468df1 100644
--- a/core/bench/dashboard/frontend/src/components/chart/mod.rs
+++ b/core/bench/dashboard/frontend/src/components/chart/mod.rs
@@ -17,6 +17,7 @@
mod plot_trend;
pub mod single_chart;
+pub mod tail_chart;
pub mod trend_chart;
use wasm_bindgen::prelude::wasm_bindgen;
diff --git a/core/bench/dashboard/frontend/src/components/chart/single_chart.rs b/core/bench/dashboard/frontend/src/components/chart/single_chart.rs
index 974e19f7e..1b366a0bc 100644
--- a/core/bench/dashboard/frontend/src/components/chart/single_chart.rs
+++ b/core/bench/dashboard/frontend/src/components/chart/single_chart.rs
@@ -105,7 +105,7 @@ pub fn single_chart(props: &SingleChartProps) -> Html {
if !is_loading {
let chart = match measurement_type {
- MeasurementType::Latency => {
+ MeasurementType::Latency | MeasurementType::Tail => {
bench_report::create_latency_chart(data, config.is_dark, true)
}
MeasurementType::Throughput => {
diff --git a/core/bench/dashboard/frontend/src/components/chart/tail_chart.rs b/core/bench/dashboard/frontend/src/components/chart/tail_chart.rs
new file mode 100644
index 000000000..e5e8e2506
--- /dev/null
+++ b/core/bench/dashboard/frontend/src/components/chart/tail_chart.rs
@@ -0,0 +1,420 @@
+// 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 bench_dashboard_shared::BenchmarkReportLight;
+use bench_report::group_metrics_summary::BenchmarkGroupMetricsSummary;
+use yew::prelude::*;
+
+#[derive(Properties, PartialEq)]
+pub struct TailChartProps {
+ pub benchmark: BenchmarkReportLight,
+ #[prop_or(None)]
+ pub on_details: Option<Callback<()>>,
+ #[prop_or_default]
+ pub compact: bool,
+}
+
+#[function_component(TailChart)]
+pub fn tail_chart(props: &TailChartProps) -> Html {
+ let hovered = use_state(|| None::<usize>);
+
+ let Some(summary) = props.benchmark.group_metrics.first().map(|m| &m.summary) else {
+ return html! {};
+ };
+
+ let points = percentile_points(summary);
+ let max_latency = points
+ .iter()
+ .map(|(_, _, latency)| *latency)
+ .fold(0.0_f64, f64::max);
+ if max_latency <= 0.0 {
+ return html! {};
+ }
+ let projected: Vec<Projected> = points
+ .iter()
+ .map(|(label, percentile, latency)| {
+ let (screen_x, screen_y) = project_percentile(*percentile, *latency, max_latency);
+ Projected {
+ x: screen_x,
+ y: screen_y,
+ latency_ms: *latency,
+ label,
+ }
+ })
+ .collect();
+
+ let line_path = polyline(&projected);
+ let area_path = closed_polyline(&projected);
+ let details_button = props.on_details.as_ref().map(|details_callback| {
+ let details_callback = details_callback.clone();
+ html! {
+ <button
+ type="button"
+ class="tail-chart-details-btn"
+ onclick={Callback::from(move |_| details_callback.emit(()))}
+ >
+ {"View details"}
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
+ fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <line x1="5" y1="12" x2="19" y2="12" />
+ <polyline points="12 5 19 12 12 19" />
+ </svg>
+ </button>
+ }
+ });
+
+ let wrap_classes = classes!(
+ "tail-chart-wrap",
+ props.compact.then_some("tail-chart-wrap-compact")
+ );
+
+ html! {
+ <div class={wrap_classes}>
+ <div class="tail-chart-header">
+ <div class="tail-chart-legend">
+ <span class="tail-chart-legend-dot" />
+ <span class="tail-chart-legend-label">{"Latency percentiles"}</span>
+ <span class="tail-chart-legend-hint">{"hover for details"}</span>
+ </div>
+ <div class="tail-chart-header-right">
+ <span class="tail-chart-subject" title={props.benchmark.params.pretty_name.clone()}>
+ { props.benchmark.params.pretty_name.clone() }
+ </span>
+ { details_button.unwrap_or_default() }
+ </div>
+ </div>
+ <svg
+ class="tail-chart"
+ viewBox={format!("0 0 {CHART_W} {CHART_H}")}
+ preserveAspectRatio="xMidYMid meet"
+ onmouseleave={ {
+ let hovered = hovered.clone();
+ Callback::from(move |_| hovered.set(None))
+ } }
+ >
+ <defs>
+ <linearGradient id="tail-area-grad" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stop-color="#ff9103" stop-opacity="0.32" />
+ <stop offset="100%" stop-color="#ff9103" stop-opacity="0" />
+ </linearGradient>
+ <filter id="tail-line-glow" x="-10%" y="-50%" width="120%" height="200%">
+ <feGaussianBlur stdDeviation="2.5" result="blur" />
+ <feMerge>
+ <feMergeNode in="blur" />
+ <feMergeNode in="SourceGraphic" />
+ </feMerge>
+ </filter>
+ <filter id="tail-dot-glow" x="-80%" y="-80%" width="260%" height="260%">
+ <feGaussianBlur stdDeviation="4" result="blur" />
+ <feMerge>
+ <feMergeNode in="blur" />
+ <feMergeNode in="SourceGraphic" />
+ </feMerge>
+ </filter>
+ </defs>
+ { render_gridlines(max_latency) }
+ <path class="tail-chart-area" d={area_path} fill="url(#tail-area-grad)" />
+ <path
+ class="tail-chart-line"
+ d={line_path}
+ fill="none"
+ stroke="#ff9103"
+ stroke-width="2.5"
+ stroke-linejoin="round"
+ stroke-linecap="round"
+ filter="url(#tail-line-glow)"
+ />
+ { render_crosshair(hovered.as_ref().and_then(|i| projected.get(*i))) }
+ { render_points(&projected, *hovered, hovered.clone()) }
+ { render_axis_labels(&projected, *hovered) }
+ { render_tooltip(hovered.as_ref().and_then(|i| projected.get(*i))) }
+ </svg>
+ </div>
+ }
+}
+
+const CHART_W: f64 = 1000.0;
+const CHART_H: f64 = 340.0;
+const MARGIN_L: f64 = 56.0;
+const MARGIN_R: f64 = 24.0;
+const MARGIN_T: f64 = 24.0;
+const MARGIN_B: f64 = 44.0;
+
+const PERCENTILE_POINTS: [(&str, f64); 6] = [
+ ("P50", 50.0),
+ ("P90", 90.0),
+ ("P95", 95.0),
+ ("P99", 99.0),
+ ("P99.9", 99.9),
+ ("P99.99", 99.99),
+];
+
+struct Projected {
+ x: f64,
+ y: f64,
+ latency_ms: f64,
+ label: &'static str,
+}
+
+fn render_gridlines(max_latency: f64) -> Html {
+ let steps = 4;
+ let items: Vec<_> = (0..=steps)
+ .map(|index| {
+ let fraction = index as f64 / steps as f64;
+ let y = CHART_H - MARGIN_B - fraction * (CHART_H - MARGIN_T - MARGIN_B);
+ let value = fraction * max_latency;
+ html! {
+ <g class="tail-chart-grid">
+ <line x1={MARGIN_L.to_string()} y1={y.to_string()}
+ x2={(CHART_W - MARGIN_R).to_string()} y2={y.to_string()} />
+ <text x={(MARGIN_L - 10.0).to_string()} y={(y + 3.5).to_string()} text-anchor="end">
+ {format_latency(value)}
+ </text>
+ </g>
+ }
+ })
+ .collect();
+ html! { <>{for items}</> }
+}
+
+fn render_axis_labels(projected: &[Projected], hovered: Option<usize>) -> Html {
+ html! {
+ <g class="tail-chart-axis-labels">
+ { for projected.iter().enumerate().map(|(index, point)| {
+ let is_active = Some(index) == hovered;
+ html! {
+ <text
+ class={if is_active { "tail-chart-axis-label-active" } else { "" }}
+ x={point.x.to_string()}
+ y={(CHART_H - MARGIN_B + 22.0).to_string()}
+ text-anchor="middle"
+ >
+ {point.label}
+ </text>
+ }
+ })}
+ </g>
+ }
+}
+
+fn render_points(
+ projected: &[Projected],
+ hovered: Option<usize>,
+ hovered_handle: UseStateHandle<Option<usize>>,
+) -> Html {
+ html! {
+ <g>
+ { for projected.iter().enumerate().map(|(index, point)| {
+ let is_active = Some(index) == hovered;
+ let on_enter = {
+ let hovered_handle = hovered_handle.clone();
+ Callback::from(move |_| hovered_handle.set(Some(index)))
+ };
+ html! {
+ <g
+ class={classes!("tail-chart-point", is_active.then_some("active"))}
+ style={format!("--stagger: {index}")}
+ onmouseenter={on_enter.clone()}
+ onmouseover={on_enter}
+ >
+ <circle
+ class="tail-chart-point-outer"
+ cx={point.x.to_string()}
+ cy={point.y.to_string()}
+ r="22"
+ fill="transparent"
+ pointer-events="all"
+ />
+ {
+ if is_active {
+ html! {
+ <circle
+ class="tail-chart-point-halo"
+ cx={point.x.to_string()}
+ cy={point.y.to_string()}
+ r="9"
+ fill="none"
+ stroke="#ff9103"
+ stroke-width="1.2"
+ stroke-opacity="0.5"
+ />
+ }
+ } else {
+ html! {}
+ }
+ }
+ <circle
+ class="tail-chart-point-dot"
+ cx={point.x.to_string()}
+ cy={point.y.to_string()}
+ r={if is_active { "4.5" } else { "3" }}
+ filter={if is_active { "url(#tail-dot-glow)" } else { "" }}
+ />
+ </g>
+ }
+ })}
+ </g>
+ }
+}
+
+fn render_crosshair(hovered: Option<&Projected>) -> Html {
+ let Some(point) = hovered else {
+ return html! {};
+ };
+ html! {
+ <g class="tail-chart-crosshair">
+ <line
+ x1={point.x.to_string()}
+ y1={MARGIN_T.to_string()}
+ x2={point.x.to_string()}
+ y2={(CHART_H - MARGIN_B).to_string()}
+ />
+ <line
+ x1={MARGIN_L.to_string()}
+ y1={point.y.to_string()}
+ x2={(CHART_W - MARGIN_R).to_string()}
+ y2={point.y.to_string()}
+ />
+ </g>
+ }
+}
+
+fn render_tooltip(hovered: Option<&Projected>) -> Html {
+ let Some(point) = hovered else {
+ return html! {};
+ };
+ let box_width = 110.0;
+ let box_height = 46.0;
+ let gap = 12.0;
+
+ let mut box_x = point.x - box_width / 2.0;
+ let mut box_y = point.y - box_height - gap;
+ if box_x < MARGIN_L {
+ box_x = MARGIN_L;
+ }
+ if box_x + box_width > CHART_W - MARGIN_R {
+ box_x = CHART_W - MARGIN_R - box_width;
+ }
+ if box_y < MARGIN_T {
+ box_y = point.y + gap;
+ }
+ let center_x = box_x + box_width / 2.0;
+
+ html! {
+ <g class="tail-chart-tooltip" pointer-events="none">
+ <rect
+ x={box_x.to_string()}
+ y={box_y.to_string()}
+ width={box_width.to_string()}
+ height={box_height.to_string()}
+ rx="8"
+ ry="8"
+ class="tail-chart-tooltip-bg"
+ />
+ <text
+ x={center_x.to_string()}
+ y={(box_y + 18.0).to_string()}
+ text-anchor="middle"
+ class="tail-chart-tooltip-label"
+ >
+ {point.label}
+ </text>
+ <text
+ x={center_x.to_string()}
+ y={(box_y + 34.0).to_string()}
+ text-anchor="middle"
+ class="tail-chart-tooltip-value"
+ >
+ {format!("{} ms", format_latency(point.latency_ms))}
+ </text>
+ </g>
+ }
+}
+
+fn polyline(projected: &[Projected]) -> String {
+ projected
+ .iter()
+ .enumerate()
+ .map(|(index, point)| {
+ let command = if index == 0 { 'M' } else { 'L' };
+ format!("{command}{:.1},{:.1}", point.x, point.y)
+ })
+ .collect()
+}
+
+fn closed_polyline(projected: &[Projected]) -> String {
+ let baseline_y = CHART_H - MARGIN_B;
+ match (projected.first(), projected.last()) {
+ (Some(first), Some(last)) => {
+ format!(
+ "M{:.1},{baseline_y:.1} {} L{:.1},{baseline_y:.1} Z",
+ first.x,
+ polyline(projected),
+ last.x
+ )
+ }
+ _ => String::new(),
+ }
+}
+
+fn percentile_points(summary: &BenchmarkGroupMetricsSummary) -> Vec<(&'static str, f64, f64)> {
+ let values = [
+ summary.average_p50_latency_ms,
+ summary.average_p90_latency_ms,
+ summary.average_p95_latency_ms,
+ summary.average_p99_latency_ms,
+ summary.average_p999_latency_ms,
+ summary.average_p9999_latency_ms,
+ ];
+ PERCENTILE_POINTS
+ .iter()
+ .zip(values.iter())
+ .map(|((label, percentile), latency)| (*label, *percentile, *latency))
+ .collect()
+}
+
+fn project_percentile(percentile: f64, latency_ms: f64, max_latency: f64) -> (f64, f64) {
+ let tail_fraction = (1.0 - percentile / 100.0).max(1e-5);
+ let x_log = -tail_fraction.log10();
+ let x_min = -(1.0 - 0.50_f64).log10();
+ let x_max = -(1.0 - 0.9999_f64).log10();
+ let span = x_max - x_min;
+ let normalized = if span > 0.0 {
+ ((x_log - x_min) / span).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ let screen_x = MARGIN_L + normalized * (CHART_W - MARGIN_L - MARGIN_R);
+
+ let y_fraction = if max_latency > 0.0 {
+ (latency_ms / max_latency).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ let screen_y = CHART_H - MARGIN_B - y_fraction * (CHART_H - MARGIN_T - MARGIN_B);
+ (screen_x, screen_y)
+}
+
+fn format_latency(ms: f64) -> String {
+ if ms >= 10.0 {
+ format!("{ms:.1}")
+ } else if ms >= 1.0 {
+ format!("{ms:.2}")
+ } else {
+ format!("{ms:.3}")
+ }
+}
diff --git a/core/bench/dashboard/frontend/src/components/chart/trend_chart.rs b/core/bench/dashboard/frontend/src/components/chart/trend_chart.rs
index 26b877c1d..f180c70de 100644
--- a/core/bench/dashboard/frontend/src/components/chart/trend_chart.rs
+++ b/core/bench/dashboard/frontend/src/components/chart/trend_chart.rs
@@ -80,9 +80,9 @@ pub fn trend_chart(props: &TrendChartProps) -> Html {
move |(data, measurement_type, is_dark, size)| {
if !data.is_empty() {
let plot_type = match measurement_type {
- MeasurementType::Latency | MeasurementType::Distribution => {
- PlotType::Latency
- }
+ MeasurementType::Latency
+ | MeasurementType::Distribution
+ | MeasurementType::Tail => PlotType::Latency,
MeasurementType::Throughput => PlotType::Throughput,
};
diff --git a/core/bench/dashboard/frontend/src/components/embed_modal.rs b/core/bench/dashboard/frontend/src/components/embed_modal.rs
index 9609eaec2..86cb00a6e 100644
--- a/core/bench/dashboard/frontend/src/components/embed_modal.rs
+++ b/core/bench/dashboard/frontend/src/components/embed_modal.rs
@@ -50,6 +50,7 @@ pub fn embed_modal(props: &EmbedModalProps) -> Html {
MeasurementType::Latency => "latency",
MeasurementType::Throughput => "throughput",
MeasurementType::Distribution => "distribution",
+ MeasurementType::Tail => "latency",
};
let theme = if props.is_dark { "dark" } else { "light" };
diff --git a/core/bench/dashboard/frontend/src/components/layout/hero.rs b/core/bench/dashboard/frontend/src/components/layout/hero.rs
new file mode 100644
index 000000000..bbecb4ead
--- /dev/null
+++ b/core/bench/dashboard/frontend/src/components/layout/hero.rs
@@ -0,0 +1,391 @@
+// 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::components::chart::tail_chart::TailChart;
+use crate::state::benchmark::{BenchmarkAction, pick_best_from_recent_batch, use_benchmark};
+use crate::state::ui::{UiAction, use_ui};
+use bench_dashboard_shared::BenchmarkReportLight;
+use chrono::DateTime;
+use yew::prelude::*;
+
+#[derive(Properties, PartialEq)]
+pub struct HeroProps {
+ pub selected_gitref: String,
+}
+
+#[function_component(Hero)]
+pub fn hero(props: &HeroProps) -> Html {
+ let benchmark_ctx = use_benchmark();
+ let ui = use_ui();
+ let unrestricted: Vec<&BenchmarkReportLight> = benchmark_ctx
+ .state
+ .entries
+ .values()
+ .flatten()
+ .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| {
+ throughput_mb(left)
+ .partial_cmp(&throughput_mb(right))
+ .unwrap_or(std::cmp::Ordering::Equal)
+ })
+ .cloned();
+ if stats.showcase.is_none() {
+ let all_entries: Vec<&BenchmarkReportLight> =
+ benchmark_ctx.state.entries.values().flatten().collect();
+ stats.showcase = pick_best_from_recent_batch(&all_entries);
+ }
+
+ 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>
+ };
+ }
+
+ let hardware = benchmark_ctx
+ .state
+ .current_hardware
+ .clone()
+ .unwrap_or_default();
+ let gitref_suffix = if props.selected_gitref.is_empty() {
+ String::new()
+ } else {
+ format!(" @ {}", props.selected_gitref)
+ };
+
+ let on_view_details = stats.showcase.as_ref().map(|showcase| {
+ let benchmark = showcase.clone();
+ let ui = ui.clone();
+ let dispatch = benchmark_ctx.dispatch.clone();
+ Callback::from(move |_| {
+ ui.dispatch(UiAction::SetLanding(false));
+ dispatch.emit(BenchmarkAction::SelectBenchmark(Box::new(Some(
+ benchmark.clone(),
+ ))));
+ })
+ });
+
+ html! {
+ <div class="hero-v2">
+ { render_background_grid() }
+ <div class="hero-v2-inner">
+ { render_headline(&stats, &hardware, &gitref_suffix) }
+ { render_stat_cards(&stats) }
+ {
+ match (stats.showcase.as_ref(), on_view_details) {
+ (Some(showcase), Some(details_callback)) => html! {
+ <TailChart
+ benchmark={showcase.clone()}
+ on_details={details_callback}
+ />
+ },
+ _ => html! {},
+ }
+ }
+ </div>
+ </div>
+ }
+}
+
+#[derive(Default)]
+struct HeroStats {
+ peak_mb_s: Option<(f64, String)>,
+ peak_msg_s: Option<(f64, String)>,
+ max_scale: Option<MaxScale>,
+ total: usize,
+ latest_ts: Option<String>,
+ showcase: Option<BenchmarkReportLight>,
+}
+
+struct MaxScale {
+ producers: u32,
+ consumers: u32,
+ pretty_name: String,
+}
+
+impl MaxScale {
+ fn total_actors(&self) -> u32 {
+ self.producers + self.consumers
+ }
+}
+
+fn compute_stats<'a>(benchmarks: impl Iterator<Item = &'a BenchmarkReportLight>) -> HeroStats {
+ let mut stats = HeroStats::default();
+
+ for benchmark in benchmarks {
+ stats.total += 1;
+ let Some(summary) = benchmark
+ .group_metrics
+ .first()
+ .map(|metrics| &metrics.summary)
+ else {
+ continue;
+ };
+ let throughput_megabytes = summary.total_throughput_megabytes_per_second;
+ let throughput_messages = summary.total_throughput_messages_per_second;
+ let pretty_name = benchmark.params.pretty_name.clone();
+
+ if stats
+ .peak_mb_s
+ .as_ref()
+ .is_none_or(|(current, _)| throughput_megabytes > *current)
+ {
+ stats.peak_mb_s = Some((throughput_megabytes, pretty_name.clone()));
+ }
+ if stats
+ .peak_msg_s
+ .as_ref()
+ .is_none_or(|(current, _)| throughput_messages > *current)
+ {
+ stats.peak_msg_s = Some((throughput_messages, pretty_name.clone()));
+ }
+ let producers = benchmark.params.producers;
+ let consumers = benchmark.params.consumers;
+ let total_actors = producers + consumers;
+ if stats
+ .max_scale
+ .as_ref()
+ .is_none_or(|current| total_actors > current.total_actors())
+ {
+ stats.max_scale = Some(MaxScale {
+ producers,
+ consumers,
+ pretty_name: pretty_name.clone(),
+ });
+ }
+ if stats
+ .latest_ts
+ .as_ref()
+ .is_none_or(|current| &benchmark.timestamp > current)
+ {
+ stats.latest_ts = Some(benchmark.timestamp.clone());
+ }
+ }
+ stats
+}
+
+fn render_background_grid() -> Html {
+ html! {
+ <div class="hero-v2-bg" aria-hidden="true">
+ <div class="hero-v2-bg-dot-grid" />
+ <div class="hero-v2-bg-glow hero-v2-bg-glow-primary" />
+ <div class="hero-v2-bg-glow hero-v2-bg-glow-secondary" />
+ </div>
+ }
+}
+
+fn render_headline(stats: &HeroStats, hardware: &str, gitref_suffix: &str) -> Html {
+ let (value, unit, subject) = match &stats.peak_mb_s {
+ Some((throughput, name)) => {
+ let (formatted, unit) = format_throughput_bytes(*throughput);
+ (formatted, unit, name.clone())
+ }
+ 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">
+ <div class="hero-v2-eyebrow">{"Peak sustained throughput"}</div>
+ <h1 class="hero-v2-title">
+ <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-tagline">
+ {"Modern hardware is incredibly capable. "}
+ <span class="hero-v2-tagline-accent">{"Iggy was built for it."}</span>
+ </p>
+ </div>
+ }
+}
+
+fn render_stat_cards(stats: &HeroStats) -> Html {
+ html! {
+ <div class="hero-v2-cards">
+ {
+ render_stat_card(0, "Peak throughput", stats.peak_msg_s.as_ref().map(|(rate, name)| {
+ let (formatted, unit) = format_msg_rate(*rate);
+ (formatted, unit, name.clone())
+ }))
+ }
+ { render_scale_card(1, stats.max_scale.as_ref()) }
+ { render_showcase_card(2, stats.showcase.as_ref()) }
+ { render_summary_card(3, stats.total, stats.latest_ts.as_deref()) }
+ </div>
+ }
+}
+
+fn render_stat_card(
+ stagger: usize,
+ label: &'static str,
+ value: Option<(String, &'static str, String)>,
+) -> Html {
+ let Some((formatted, unit, name)) = value else {
+ return html! {};
+ };
+ html! {
+ <div class="hero-v2-card" style={format!("--stagger: {stagger}")}>
+ <div class="hero-v2-card-value-row">
+ <span class="hero-v2-card-value">{formatted}</span>
+ <span class="hero-v2-card-unit">{unit}</span>
+ </div>
+ <div class="hero-v2-card-label">{label}</div>
+ <div class="hero-v2-card-sub" title={name.clone()}>{name}</div>
+ </div>
+ }
+}
+
+fn render_scale_card(stagger: usize, scale: Option<&MaxScale>) -> Html {
+ let Some(scale) = scale else {
+ return html! {};
+ };
+ let total = scale.total_actors();
+ let breakdown = if scale.producers > 0 && scale.consumers > 0 {
+ format!(
+ "{} producers × {} consumers",
+ scale.producers, scale.consumers
+ )
+ } else if scale.producers > 0 {
+ format!("{} producers", scale.producers)
+ } else {
+ format!("{} consumers", scale.consumers)
+ };
+ html! {
+ <div class="hero-v2-card" style={format!("--stagger: {stagger}")}>
+ <div class="hero-v2-card-value-row">
+ <span class="hero-v2-card-value">{total}</span>
+ <span class="hero-v2-card-unit">{"actors"}</span>
+ </div>
+ <div class="hero-v2-card-label">{"Max scale tested"}</div>
+ <div class="hero-v2-card-sub" title={scale.pretty_name.clone()}>{breakdown}</div>
+ </div>
+ }
+}
+
+fn render_showcase_card(stagger: usize, showcase: Option<&BenchmarkReportLight>) -> Html {
+ let Some(benchmark) = showcase else {
+ return html! {};
+ };
+ let Some(summary) = benchmark.group_metrics.first().map(|m| &m.summary) else {
+ return html! {};
+ };
+ let p99 = summary.average_p99_latency_ms;
+ let name = benchmark.params.pretty_name.clone();
+
+ html! {
+ <div class="hero-v2-card hero-v2-card-accent" style={format!("--stagger: {stagger}")}>
+ <div class="hero-v2-card-value-row">
+ <span class="hero-v2-card-value">{format_latency(p99)}</span>
+ <span class="hero-v2-card-unit">{"ms"}</span>
+ </div>
+ <div class="hero-v2-card-label">{"P99 at peak throughput"}</div>
+ <div class="hero-v2-card-sub" title={name.clone()}>{name}</div>
+ </div>
+ }
+}
+
+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)),
+ None => String::new(),
+ };
+ html! {
+ <div class="hero-v2-card" style={format!("--stagger: {stagger}")}>
+ <div class="hero-v2-card-value-row">
+ <span class="hero-v2-card-value">{total}</span>
+ <span class="hero-v2-card-unit">{"runs"}</span>
+ </div>
+ <div class="hero-v2-card-label">{"Benchmarks loaded"}</div>
+ <div class="hero-v2-card-sub">{sub}</div>
+ </div>
+ }
+}
+
+fn format_throughput_bytes(mb_per_s: f64) -> (String, &'static str) {
+ if mb_per_s >= 1_000_000.0 {
+ (format_significant(mb_per_s / 1_000_000.0), "TB/s")
+ } else if mb_per_s >= 1_000.0 {
+ (format_significant(mb_per_s / 1_000.0), "GB/s")
+ } else {
+ (format_significant(mb_per_s), "MB/s")
+ }
+}
+
+fn format_msg_rate(rate: f64) -> (String, &'static str) {
+ if rate >= 1_000_000_000.0 {
+ (format_significant(rate / 1_000_000_000.0), "B msg/s")
+ } else if rate >= 1_000_000.0 {
+ (format_significant(rate / 1_000_000.0), "M msg/s")
+ } else if rate >= 1_000.0 {
+ (format_significant(rate / 1_000.0), "k msg/s")
+ } else {
+ (format!("{rate:.0}"), "msg/s")
+ }
+}
+
+fn format_significant(v: f64) -> String {
+ if v >= 100.0 {
+ format!("{v:.0}")
+ } else if v >= 10.0 {
+ format!("{v:.1}")
+ } else {
+ format!("{v:.2}")
+ }
+}
+
+fn format_latency(ms: f64) -> String {
+ if ms >= 10.0 {
+ format!("{ms:.1}")
+ } else if ms >= 1.0 {
+ format!("{ms:.2}")
+ } else {
+ format!("{ms:.3}")
+ }
+}
+
+fn format_date(timestamp_str: &str) -> String {
+ match DateTime::parse_from_rfc3339(timestamp_str) {
+ Ok(t) => t.format("%Y-%m-%d").to_string(),
+ Err(_) => "unknown".to_string(),
+ }
+}
diff --git a/core/bench/dashboard/frontend/src/components/layout/logo.rs b/core/bench/dashboard/frontend/src/components/layout/logo.rs
index 876acb1e1..7c0f64d4d 100644
--- a/core/bench/dashboard/frontend/src/components/layout/logo.rs
+++ b/core/bench/dashboard/frontend/src/components/layout/logo.rs
@@ -15,21 +15,46 @@
// specific language governing permissions and limitations
// under the License.
+use crate::router::AppRoute;
+use crate::state::benchmark::{BenchmarkAction, use_benchmark};
+use crate::state::ui::{UiAction, ViewMode, use_ui};
use yew::prelude::*;
+use yew_router::prelude::use_navigator;
#[function_component(Logo)]
pub fn logo() -> Html {
let (is_dark, _) = use_context::<(bool, Callback<()>)>().expect("Theme context not found");
- let logo_src = if !is_dark {
- "/assets/iggy-dark.png"
- } else {
+ let ui = use_ui();
+ let benchmark_ctx = use_benchmark();
+ let navigator = use_navigator();
+ let logo_src = if is_dark {
"/assets/iggy-light.png"
+ } else {
+ "/assets/iggy-dark.png"
+ };
+
+ let on_click = {
+ let ui = ui.clone();
+ let benchmark_ctx = benchmark_ctx.clone();
+ let navigator = navigator.clone();
+ Callback::from(move |_| {
+ benchmark_ctx
+ .dispatch
+ .emit(BenchmarkAction::SelectBenchmark(Box::new(None)));
+ ui.dispatch(UiAction::SetComparePin(None));
+ ui.dispatch(UiAction::SetViewMode(ViewMode::SingleGitref));
+ ui.dispatch(UiAction::SetLanding(true));
+ if let Some(nav) = navigator.as_ref() {
+ nav.push(&AppRoute::Home);
+ }
+ })
};
html! {
- <div class="logo">
+ <button type="button" class="logo logo-home-button" onclick={on_click}
+ title="Back to overview">
<img src={logo_src} alt="Apache Iggy Logo" />
<h1>{"Apache Iggy Benchmarks"}</h1>
- </div>
+ </button>
}
}
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 56f53324e..ff8140299 100644
--- a/core/bench/dashboard/frontend/src/components/layout/main_content.rs
+++ b/core/bench/dashboard/frontend/src/components/layout/main_content.rs
@@ -16,9 +16,15 @@
// under the License.
use crate::components::chart::single_chart::SingleChart;
+use crate::components::chart::tail_chart::TailChart;
+use crate::components::layout::hero::Hero;
+use crate::components::layout::percentile_card::PercentileCard;
+use crate::components::layout::sweep_view::SweepView;
use crate::components::layout::topbar::TopBar;
+use crate::components::selectors::measurement_type_selector::MeasurementType;
use crate::state::benchmark::use_benchmark;
-use crate::state::ui::{ViewMode, use_ui};
+use crate::state::ui::{UiAction, ViewMode, use_ui};
+use bench_dashboard_shared::BenchmarkReportLight;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
@@ -33,77 +39,175 @@ pub struct MainContentProps {
pub fn main_content(props: &MainContentProps) -> Html {
let benchmark_ctx = use_benchmark();
let ui = use_ui();
- let selected_measurement = ui.selected_measurement.clone();
-
let is_recent_view = matches!(props.view_mode, ViewMode::RecentBenchmarks);
+ let selected = benchmark_ctx.state.selected_benchmark.clone();
+ let pinned = ui.compare_pin.as_deref().cloned();
+ let entries = benchmark_ctx.state.entries.clone();
- let content = if let Some(selected_benchmark) = &benchmark_ctx.state.selected_benchmark {
- html! {
- <div class="content-wrapper">
- <div class="chart-title">
- <div class="chart-title-primary">
- { selected_benchmark.title(&selected_measurement.to_string()) }
- </div>
- <div class="chart-title-sub">
- { selected_benchmark.subtext() }
- </div>
- <div class="chart-title-identifier">
- { selected_benchmark.identifier_with_cpu_and_version() }
- </div>
- </div>
- <div class="single-view">
- <SingleChart
- benchmark_uuid={selected_benchmark.uuid}
- measurement_type={selected_measurement.clone()}
- is_dark={props.is_dark}
- />
- </div>
- </div>
- }
- } else if is_recent_view {
+ let on_unpin = {
+ let ui = ui.clone();
+ Callback::from(move |_: MouseEvent| ui.dispatch(UiAction::SetComparePin(None)))
+ };
+
+ let show_hero = ui.is_landing && !is_recent_view;
+ let content = if show_hero {
html! {
<div class="content-wrapper">
- <div class="empty-state">
- <div class="empty-state-content">
- <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
- <polyline points="13 2 13 9 20 9"/>
- <line x1="16" y1="13" x2="8" y2="13"/>
- <line x1="16" y1="17" x2="8" y2="17"/>
- </svg>
- <h2>{"Select a recent benchmark"}</h2>
- <p>{"Choose a benchmark from the sidebar to display performance data."}</p>
- </div>
- </div>
+ <Hero selected_gitref={props.selected_gitref.clone()} />
</div>
}
} else {
- html! {
- <div class="content-wrapper">
- <div class="empty-state">
- <div class="empty-state-content">
- <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
- <polyline points="13 2 13 9 20 9"/>
- <line x1="16" y1="13" x2="8" y2="13"/>
- <line x1="16" y1="17" x2="8" y2="17"/>
- </svg>
- <h2>{"Select a benchmark to view results"}</h2>
- <p>{"Choose a benchmark from the sidebar to display performance data."}</p>
- </div>
+ match (selected.as_ref(), pinned.as_ref()) {
+ (Some(selected_benchmark), Some(pinned_benchmark))
+ if selected_benchmark.uuid != pinned_benchmark.uuid =>
+ {
+ render_compare(
+ selected_benchmark,
+ pinned_benchmark,
+ ui.selected_measurement.clone(),
+ props.is_dark,
+ on_unpin,
+ )
+ }
+ (Some(selected_benchmark), _) => render_single(
+ selected_benchmark,
+ ui.selected_measurement.clone(),
+ props.is_dark,
+ &entries,
+ ),
+ (None, _) if is_recent_view => render_empty_recent(),
+ (None, _) => html! {
+ <div class="content-wrapper">
+ <Hero selected_gitref={props.selected_gitref.clone()} />
</div>
- </div>
+ },
}
};
html! {
<div class="content">
- <TopBar
- is_dark={props.is_dark}
- selected_gitref={props.selected_gitref.clone()}
- on_theme_toggle={props.on_theme_toggle.clone()}
- />
+ if !show_hero {
+ <TopBar
+ is_dark={props.is_dark}
+ selected_gitref={props.selected_gitref.clone()}
+ on_theme_toggle={props.on_theme_toggle.clone()}
+ />
+ }
{content}
</div>
}
}
+
+fn render_single(
+ benchmark: &BenchmarkReportLight,
+ measurement: MeasurementType,
+ is_dark: bool,
+ entries: &std::collections::BTreeMap<
+ bench_report::benchmark_kind::BenchmarkKind,
+ Vec<BenchmarkReportLight>,
+ >,
+) -> Html {
+ html! {
+ <div class="content-wrapper">
+ <div class="chart-title">
+ <div class="chart-title-primary">
+ { benchmark.title(&measurement.to_string()) }
+ </div>
+ <div class="chart-title-sub">{ benchmark.subtext() }</div>
+ <div class="chart-title-identifier">
+ { benchmark.identifier_with_cpu_and_version() }
+ </div>
+ </div>
+ <PercentileCard benchmark={benchmark.clone()} />
+ <div class="single-view">
+ { render_measurement_chart(benchmark, measurement, is_dark) }
+ </div>
+ <SweepView benchmark={benchmark.clone()} entries={entries.clone()} is_dark={is_dark} />
+ </div>
+ }
+}
+
+fn render_measurement_chart(
+ benchmark: &BenchmarkReportLight,
+ measurement: MeasurementType,
+ is_dark: bool,
+) -> Html {
+ if measurement == MeasurementType::Tail {
+ return html! { <TailChart benchmark={benchmark.clone()} /> };
+ }
+ html! {
+ <SingleChart
+ benchmark_uuid={benchmark.uuid}
+ measurement_type={measurement}
+ is_dark={is_dark}
+ />
+ }
+}
+
+fn render_compare(
+ selected_benchmark: &BenchmarkReportLight,
+ pinned_benchmark: &BenchmarkReportLight,
+ measurement: MeasurementType,
+ is_dark: bool,
+ on_unpin: Callback<MouseEvent>,
+) -> Html {
+ html! {
+ <div class="content-wrapper compare-wrapper">
+ <div class="compare-banner">
+ <span class="compare-banner-label">{"Compare mode"}</span>
+ <button type="button" class="compare-banner-unpin" onclick={on_unpin}>
+ {"Unpin"}
+ </button>
+ </div>
+ <div class="compare-grid">
+ { render_compare_pane("A - selected", selected_benchmark, measurement.clone(), is_dark) }
+ { render_compare_pane("B - pinned", pinned_benchmark, measurement, is_dark) }
+ </div>
+ </div>
+ }
+}
+
+fn render_compare_pane(
+ label: &str,
+ benchmark: &BenchmarkReportLight,
+ measurement: MeasurementType,
+ is_dark: bool,
+) -> Html {
+ html! {
+ <div class="compare-pane">
+ <div class="compare-pane-label">{label}</div>
+ <div class="chart-title">
+ <div class="chart-title-primary">
+ { benchmark.title(&measurement.to_string()) }
+ </div>
+ <div class="chart-title-sub">{ benchmark.subtext() }</div>
+ <div class="chart-title-identifier">
+ { benchmark.identifier_with_cpu_and_version() }
+ </div>
+ </div>
+ <PercentileCard benchmark={benchmark.clone()} />
+ <div class="single-view">
+ { render_measurement_chart(benchmark, measurement, is_dark) }
+ </div>
+ </div>
+ }
+}
+
+fn render_empty_recent() -> Html {
+ html! {
+ <div class="content-wrapper">
+ <div class="empty-state">
+ <div class="empty-state-content">
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
+ <polyline points="13 2 13 9 20 9"/>
+ <line x1="16" y1="13" x2="8" y2="13"/>
+ <line x1="16" y1="17" x2="8" y2="17"/>
+ </svg>
+ <h2>{"Select a recent benchmark"}</h2>
+ <p>{"Choose a benchmark from the sidebar to display performance data."}</p>
+ </div>
+ </div>
+ </div>
+ }
+}
diff --git a/core/bench/dashboard/frontend/src/components/layout/mod.rs b/core/bench/dashboard/frontend/src/components/layout/mod.rs
index 1357be355..a1dd4934d 100644
--- a/core/bench/dashboard/frontend/src/components/layout/mod.rs
+++ b/core/bench/dashboard/frontend/src/components/layout/mod.rs
@@ -15,7 +15,12 @@
// specific language governing permissions and limitations
// under the License.
+pub mod hero;
pub mod logo;
pub mod main_content;
+pub mod percentile_card;
pub mod sidebar;
+pub mod sidebar_toggle;
+pub mod sweep_view;
+pub mod theme_toggle_floating;
pub mod topbar;
diff --git a/core/bench/dashboard/frontend/src/components/layout/percentile_card.rs b/core/bench/dashboard/frontend/src/components/layout/percentile_card.rs
new file mode 100644
index 000000000..bab3e8024
--- /dev/null
+++ b/core/bench/dashboard/frontend/src/components/layout/percentile_card.rs
@@ -0,0 +1,93 @@
+// 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 bench_dashboard_shared::BenchmarkReportLight;
+use yew::prelude::*;
+
+#[derive(Properties, PartialEq)]
+pub struct PercentileCardProps {
+ pub benchmark: BenchmarkReportLight,
+}
+
+#[function_component(PercentileCard)]
+pub fn percentile_card(props: &PercentileCardProps) -> Html {
+ let Some(summary) = props
+ .benchmark
+ .group_metrics
+ .first()
+ .map(|m| m.summary.clone())
+ else {
+ return html! {};
+ };
+
+ let rows = [
+ ("P50", summary.average_p50_latency_ms),
+ ("P95", summary.average_p95_latency_ms),
+ ("P99", summary.average_p99_latency_ms),
+ ("P99.9", summary.average_p999_latency_ms),
+ ("P99.99", summary.average_p9999_latency_ms),
+ ];
+ let max_value = rows
+ .iter()
+ .map(|(_, latency)| *latency)
+ .fold(0.0_f64, |acc, v| if v > acc { v } else { acc });
+
+ if max_value <= 0.0 {
+ return html! {};
+ }
+
+ html! {
+ <div class="percentile-strip">
+ <div class="percentile-strip-label">
+ <span class="percentile-strip-title">{"Latency"}</span>
+ <span class="percentile-strip-unit">{"ms"}</span>
+ </div>
+ <div class="percentile-strip-chips">
+ { for rows.iter().map(|(label, value)| render_chip(label, *value, max_value)) }
+ </div>
+ </div>
+ }
+}
+
+fn render_chip(label: &str, value: f64, max_value: f64) -> Html {
+ let percent = if max_value > 0.0 {
+ (value / max_value * 100.0).clamp(0.0, 100.0)
+ } else {
+ 0.0
+ };
+ html! {
+ <div class="percentile-chip" title={format!("{label}: {value:.3} ms")}>
+ <div class="percentile-chip-head">
+ <span class="percentile-chip-label">{label}</span>
+ <span class="percentile-chip-value">{format_latency(value)}</span>
+ </div>
+ <div class="percentile-chip-bar">
+ <div class="percentile-chip-fill" style={format!("width: {percent:.1}%")} />
+ </div>
+ </div>
+ }
+}
+
+fn format_latency(ms: f64) -> String {
+ if ms >= 10.0 {
+ format!("{ms:.1}")
+ } else if ms >= 1.0 {
+ format!("{ms:.2}")
+ } else {
+ format!("{ms:.3}")
+ }
+}
diff --git a/core/bench/dashboard/frontend/src/components/layout/sidebar.rs b/core/bench/dashboard/frontend/src/components/layout/sidebar.rs
index ce1fc00db..498a844c0 100644
--- a/core/bench/dashboard/frontend/src/components/layout/sidebar.rs
+++ b/core/bench/dashboard/frontend/src/components/layout/sidebar.rs
@@ -20,6 +20,7 @@ use crate::components::selectors::benchmark_search_box::BenchmarkSearchBox;
use crate::components::selectors::benchmark_selector::BenchmarkSelector;
use crate::components::selectors::gitref_selector::GitrefSelector;
use crate::components::selectors::hardware_selector::HardwareSelector;
+use crate::components::selectors::param_filters_panel::ParamFiltersPanel;
use crate::components::selectors::recent_benchmarks_selector::RecentBenchmarksSelector;
use crate::components::selectors::view_mode_selector::ViewModeSelector;
use crate::state::benchmark::{BenchmarkAction, use_benchmark};
@@ -86,12 +87,14 @@ pub fn sidebar(props: &SidebarProps) -> Html {
)
};
+ let filters = ui_state.param_filters.clone();
+
let has_benchmarks = |f: fn(&BenchmarkKind) -> bool| {
- benchmark_ctx
- .state
- .entries
- .values()
- .any(|benchmarks| benchmarks.iter().any(|b| f(&b.params.benchmark_kind)))
+ benchmark_ctx.state.entries.values().any(|benchmarks| {
+ benchmarks
+ .iter()
+ .any(|b| f(&b.params.benchmark_kind) && filters.matches(b))
+ })
};
let count_benchmarks = |f: fn(&BenchmarkKind) -> bool| {
@@ -102,7 +105,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
.map(|benchmarks| {
benchmarks
.iter()
- .filter(|b| f(&b.params.benchmark_kind))
+ .filter(|b| f(&b.params.benchmark_kind) && filters.matches(b))
.count()
})
.sum::<usize>()
@@ -139,6 +142,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
<div class="sidebar-fixed-header">
<Logo />
<ViewModeSelector />
+ <ParamFiltersPanel />
if !is_recent_view {
<HardwareSelector
diff --git a/core/bench/dashboard/frontend/src/components/layout/sidebar_toggle.rs b/core/bench/dashboard/frontend/src/components/layout/sidebar_toggle.rs
new file mode 100644
index 000000000..27efa732a
--- /dev/null
+++ b/core/bench/dashboard/frontend/src/components/layout/sidebar_toggle.rs
@@ -0,0 +1,57 @@
+// 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::ui::{UiAction, use_ui};
+use yew::prelude::*;
+
+#[function_component(SidebarToggle)]
+pub fn sidebar_toggle() -> Html {
+ let ui = use_ui();
+ let is_collapsed = ui.is_sidebar_collapsed;
+
+ let on_click = {
+ let ui = ui.clone();
+ Callback::from(move |_| ui.dispatch(UiAction::ToggleSidebar))
+ };
+
+ let title = if is_collapsed {
+ "Show sidebar"
+ } else {
+ "Hide sidebar"
+ };
+
+ html! {
+ <button
+ type="button"
+ class={classes!("sidebar-toggle-floating", is_collapsed.then_some("collapsed"))}
+ onclick={on_click}
+ title={title}
+ aria-label={title}
+ >
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
+ fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ {
+ if is_collapsed {
+ html! { <polyline points="9 18 15 12 9 6" /> }
+ } else {
+ html! { <polyline points="15 18 9 12 15 6" /> }
+ }
+ }
+ </svg>
+ </button>
+ }
+}
diff --git a/core/bench/dashboard/frontend/src/components/layout/sweep_view.rs b/core/bench/dashboard/frontend/src/components/layout/sweep_view.rs
new file mode 100644
index 000000000..5e556d570
--- /dev/null
+++ b/core/bench/dashboard/frontend/src/components/layout/sweep_view.rs
@@ -0,0 +1,290 @@
+// 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 bench_dashboard_shared::BenchmarkReportLight;
+use bench_report::benchmark_kind::BenchmarkKind;
+use bench_report::params::BenchmarkParams;
+use std::collections::BTreeMap;
+use yew::prelude::*;
+
+#[derive(Properties, PartialEq)]
+pub struct SweepViewProps {
+ pub benchmark: BenchmarkReportLight,
+ pub entries: BTreeMap<BenchmarkKind, Vec<BenchmarkReportLight>>,
+ pub is_dark: bool,
+}
+
+#[function_component(SweepView)]
+pub fn sweep_view(props: &SweepViewProps) -> Html {
+ let Some((axis, points)) = find_sweep(&props.benchmark, &props.entries) else {
+ return html! {};
+ };
+
+ let max_throughput = points
+ .iter()
+ .map(|point| point.throughput_mb_s)
+ .fold(0.0_f64, f64::max);
+ let max_p99 = points
+ .iter()
+ .map(|point| point.p99_ms)
+ .fold(0.0_f64, f64::max);
+ let min_axis = points.first().map(|point| point.axis_value).unwrap_or(0);
+ let max_axis = points
+ .last()
+ .map(|point| point.axis_value)
+ .unwrap_or(1)
+ .max(1);
+
+ html! {
+ <div class="sweep-view">
+ <div class="sweep-view-title">
+ {"Scaling sweep: "}
+ <span class="sweep-axis">{axis.label()}</span>
+ <span class="sweep-view-hint">{format!("{} matched runs", points.len())}</span>
+ </div>
+ <svg class="sweep-chart" viewBox="0 0 800 260" preserveAspectRatio="none">
+ { render_grid() }
+ { render_tput_series(&points, min_axis, max_axis, max_throughput) }
+ { render_p99_series(&points, min_axis, max_axis, max_p99) }
+ { render_axes_labels(axis.label(), min_axis, max_axis, max_throughput, max_p99) }
+ </svg>
+ <div class="sweep-legend">
+ <span class="sweep-legend-item sweep-legend-tput">{"Throughput (MB/s)"}</span>
+ <span class="sweep-legend-item sweep-legend-p99">{"P99 latency (ms)"}</span>
+ </div>
+ </div>
+ }
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum SweepAxis {
+ Producers,
+ Consumers,
+ Streams,
+ Partitions,
+ ConsumerGroups,
+ MessageSize,
+ MessagesPerBatch,
+ MessageBatches,
+}
+
+impl SweepAxis {
+ const ALL: [SweepAxis; 8] = [
+ SweepAxis::Producers,
+ SweepAxis::Consumers,
+ SweepAxis::Streams,
+ SweepAxis::Partitions,
+ SweepAxis::ConsumerGroups,
+ SweepAxis::MessageSize,
+ SweepAxis::MessagesPerBatch,
+ SweepAxis::MessageBatches,
+ ];
+
+ fn label(self) -> &'static str {
+ match self {
+ SweepAxis::Producers => "producers",
+ SweepAxis::Consumers => "consumers",
+ SweepAxis::Streams => "streams",
+ SweepAxis::Partitions => "partitions",
+ SweepAxis::ConsumerGroups => "consumer groups",
+ SweepAxis::MessageSize => "message size",
+ SweepAxis::MessagesPerBatch => "messages per batch",
+ SweepAxis::MessageBatches => "message batches",
+ }
+ }
+
+ fn value(self, params: &BenchmarkParams) -> u32 {
+ match self {
+ SweepAxis::Producers => params.producers,
+ SweepAxis::Consumers => params.consumers,
+ SweepAxis::Streams => params.streams,
+ SweepAxis::Partitions => params.partitions,
+ SweepAxis::ConsumerGroups => params.consumer_groups,
+ SweepAxis::MessageSize => params.message_size.min(),
+ SweepAxis::MessagesPerBatch => params.messages_per_batch.min(),
+ SweepAxis::MessageBatches => params.message_batches as u32,
+ }
+ }
+}
+
+#[derive(Clone, Copy)]
+struct Point {
+ axis_value: u32,
+ throughput_mb_s: f64,
+ p99_ms: f64,
+}
+
+fn find_sweep(
+ selected: &BenchmarkReportLight,
+ entries: &BTreeMap<BenchmarkKind, Vec<BenchmarkReportLight>>,
+) -> Option<(SweepAxis, Vec<Point>)> {
+ let mut best: Option<(SweepAxis, Vec<Point>)> = None;
+ for axis in SweepAxis::ALL {
+ let mut points: Vec<Point> = entries
+ .values()
+ .flatten()
+ .filter(|benchmark| siblings_match(selected, benchmark, axis))
+ .filter_map(|benchmark| {
+ benchmark
+ .group_metrics
+ .first()
+ .map(|group| (benchmark, group))
+ })
+ .map(|(benchmark, group)| Point {
+ axis_value: axis.value(&benchmark.params),
+ throughput_mb_s: group.summary.total_throughput_megabytes_per_second,
+ p99_ms: group.summary.average_p99_latency_ms,
+ })
+ .collect();
+
+ points.sort_by_key(|point| point.axis_value);
+ points.dedup_by_key(|point| point.axis_value);
+
+ if points.len() >= 2
+ && best
+ .as_ref()
+ .is_none_or(|(_, previous)| points.len() > previous.len())
+ {
+ best = Some((axis, points));
+ }
+ }
+ best
+}
+
+fn siblings_match(
+ selected: &BenchmarkReportLight,
+ candidate: &BenchmarkReportLight,
+ varying_axis: SweepAxis,
+) -> bool {
+ if selected.params.benchmark_kind != candidate.params.benchmark_kind {
+ return false;
+ }
+ if selected.params.transport != candidate.params.transport
+ || selected.params.remark != candidate.params.remark
+ {
+ return false;
+ }
+ SweepAxis::ALL
+ .iter()
+ .filter(|axis| **axis != varying_axis)
+ .all(|axis| axis.value(&selected.params) == axis.value(&candidate.params))
+}
+
+fn render_grid() -> Html {
+ html! {
+ <g class="sweep-grid">
+ <line x1="60" y1="20" x2="60" y2="220" />
+ <line x1="60" y1="220" x2="780" y2="220" />
+ <line x1="780" y1="20" x2="780" y2="220" />
+ </g>
+ }
+}
+
+fn render_tput_series(points: &[Point], min_axis: u32, max_axis: u32, max_throughput: f64) -> Html {
+ if max_throughput <= 0.0 {
+ return html! {};
+ }
+ let path = polyline_path(points, min_axis, max_axis, max_throughput, |point| {
+ point.throughput_mb_s
+ });
+ html! {
+ <g class="sweep-series sweep-series-tput">
+ <polyline points={path} fill="none" stroke="currentColor" stroke-width="2.5" />
+ { for points.iter().map(|point| {
+ let (screen_x, screen_y) = project(point.axis_value, point.throughput_mb_s, min_axis, max_axis, max_throughput);
+ html! { <circle cx={screen_x.to_string()} cy={screen_y.to_string()} r="3.5" fill="currentColor" /> }
+ })}
+ </g>
+ }
+}
+
+fn render_p99_series(points: &[Point], min_axis: u32, max_axis: u32, max_p99: f64) -> Html {
+ if max_p99 <= 0.0 {
+ return html! {};
+ }
+ let path = polyline_path(points, min_axis, max_axis, max_p99, |point| point.p99_ms);
+ html! {
+ <g class="sweep-series sweep-series-p99">
+ <polyline points={path} fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="4 4" />
+ { for points.iter().map(|point| {
+ let (screen_x, screen_y) = project(point.axis_value, point.p99_ms, min_axis, max_axis, max_p99);
+ html! { <circle cx={screen_x.to_string()} cy={screen_y.to_string()} r="3" fill="currentColor" /> }
+ })}
+ </g>
+ }
+}
+
+fn render_axes_labels(
+ axis_label: &str,
+ min_axis: u32,
+ max_axis: u32,
+ max_throughput: f64,
+ max_p99: f64,
+) -> Html {
+ html! {
+ <g class="sweep-labels">
+ <text x="60" y="240" text-anchor="start">{min_axis}</text>
+ <text x="780" y="240" text-anchor="end">{max_axis}</text>
+ <text x="420" y="254" text-anchor="middle" class="sweep-axis-label">{axis_label}</text>
+ <text x="55" y="25" text-anchor="end" class="sweep-y-tput">{format!("{max_throughput:.0} MB/s")}</text>
+ <text x="55" y="220" text-anchor="end" class="sweep-y-tput">{"0"}</text>
+ <text x="785" y="25" text-anchor="start" class="sweep-y-p99">{format!("{max_p99:.1} ms")}</text>
+ <text x="785" y="220" text-anchor="start" class="sweep-y-p99">{"0"}</text>
+ </g>
+ }
+}
+
+fn polyline_path(
+ points: &[Point],
+ min_axis: u32,
+ max_axis: u32,
+ max_value: f64,
+ extract_value: impl Fn(&Point) -> f64,
+) -> String {
+ points
+ .iter()
+ .map(|point| {
+ let (screen_x, screen_y) = project(
+ point.axis_value,
+ extract_value(point),
+ min_axis,
+ max_axis,
+ max_value,
+ );
+ format!("{screen_x:.1},{screen_y:.1}")
+ })
+ .collect::<Vec<_>>()
+ .join(" ")
+}
+
+fn project(
+ axis_value: u32,
+ y_value: f64,
+ min_axis: u32,
+ max_axis: u32,
+ max_value: f64,
+) -> (f64, f64) {
+ let span = (max_axis.saturating_sub(min_axis)).max(1) as f64;
+ let normalized_x = (axis_value.saturating_sub(min_axis)) as f64 / span;
+ let screen_x = 60.0 + normalized_x * 720.0;
+ let screen_y = if max_value > 0.0 {
+ 220.0 - (y_value / max_value).clamp(0.0, 1.0) * 200.0
+ } else {
+ 220.0
+ };
+ (screen_x, screen_y)
+}
diff --git a/core/bench/dashboard/frontend/src/components/layout/theme_toggle_floating.rs b/core/bench/dashboard/frontend/src/components/layout/theme_toggle_floating.rs
new file mode 100644
index 000000000..716f58108
--- /dev/null
+++ b/core/bench/dashboard/frontend/src/components/layout/theme_toggle_floating.rs
@@ -0,0 +1,66 @@
+// 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 yew::prelude::*;
+
+#[function_component(ThemeToggleFloating)]
+pub fn theme_toggle_floating() -> Html {
+ let (is_dark, toggle) = use_context::<(bool, Callback<()>)>().expect("Theme context not found");
+
+ let onclick = {
+ let toggle = toggle.clone();
+ Callback::from(move |_| toggle.emit(()))
+ };
+
+ let title = if is_dark {
+ "Switch to light theme"
+ } else {
+ "Switch to dark theme"
+ };
+
+ html! {
+ <button
+ type="button"
+ class="theme-toggle-floating"
+ onclick={onclick}
+ title={title}
+ aria-label={title}
+ >
+ if is_dark {
+ <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">
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+ </svg>
+ } else {
+ <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="12" cy="12" r="5" />
+ <line x1="12" y1="1" x2="12" y2="3" />
+ <line x1="12" y1="21" x2="12" y2="23" />
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
+ <line x1="1" y1="12" x2="3" y2="12" />
+ <line x1="21" y1="12" x2="23" y2="12" />
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
+ </svg>
+ }
+ </button>
+ }
+}
diff --git a/core/bench/dashboard/frontend/src/components/layout/topbar.rs b/core/bench/dashboard/frontend/src/components/layout/topbar.rs
index 49368d24f..232bd8f9f 100644
--- a/core/bench/dashboard/frontend/src/components/layout/topbar.rs
+++ b/core/bench/dashboard/frontend/src/components/layout/topbar.rs
@@ -18,7 +18,6 @@
use crate::api;
use crate::components::embed_modal::EmbedModal;
use crate::components::selectors::measurement_type_selector::MeasurementType;
-use crate::components::theme::theme_toggle::ThemeToggle;
use crate::components::tooltips::benchmark_info_toggle::BenchmarkInfoToggle;
use crate::components::tooltips::benchmark_info_tooltip::BenchmarkInfoTooltip;
use crate::components::tooltips::server_stats_toggle::ServerStatsToggle;
@@ -100,10 +99,6 @@ pub fn topbar(props: &TopBarProps) -> Html {
html! {
<div class="top-buttons">
<div class="controls">
- <ThemeToggle
- is_dark={props.is_dark}
- on_toggle={props.on_theme_toggle.clone()}
- />
{
if !props.selected_gitref.is_empty() {
html! {
@@ -227,6 +222,15 @@ pub fn topbar(props: &TopBarProps) -> Html {
>
{ "Throughput" }
</button>
+ <button
+ class={classes!(
+ "measurement-button",
+ (selected_measurement == MeasurementType::Tail).then_some("active")
+ )}
+ onclick={on_measurement_select.reform(|_| MeasurementType::Tail)}
+ >
+ { "Tail" }
+ </button>
</div>
</>
}
diff --git a/core/bench/dashboard/frontend/src/components/selectors/benchmark_selector.rs b/core/bench/dashboard/frontend/src/components/selectors/benchmark_selector.rs
index d93daedf4..a0ad760f5 100644
--- a/core/bench/dashboard/frontend/src/components/selectors/benchmark_selector.rs
+++ b/core/bench/dashboard/frontend/src/components/selectors/benchmark_selector.rs
@@ -18,9 +18,12 @@
use crate::{
components::selectors::benchmark_kind_selector::BenchmarkKindSelector,
state::benchmark::{BenchmarkAction, use_benchmark},
+ state::ui::{UiAction, use_ui},
};
+use bench_dashboard_shared::BenchmarkReportLight;
use bench_report::benchmark_kind::BenchmarkKind;
use std::collections::HashSet;
+use uuid::Uuid;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
@@ -31,63 +34,49 @@ pub struct BenchmarkSelectorProps {
#[function_component(BenchmarkSelector)]
pub fn benchmark_selector(props: &BenchmarkSelectorProps) -> Html {
let benchmark_ctx = use_benchmark();
+ let ui_state = use_ui();
+ let filters = ui_state.param_filters.clone();
+ let pinned_uuid = ui_state.compare_pin.as_ref().map(|b| b.uuid);
let selected_kind = benchmark_ctx.state.selected_kind;
+ let selected_uuid = benchmark_ctx
+ .state
+ .selected_benchmark
+ .as_ref()
+ .map(|b| b.uuid);
let available_kinds: HashSet<_> = benchmark_ctx
.state
.entries
.keys()
- .filter(|k| match props.kind {
- BenchmarkKind::PinnedProducer
- | BenchmarkKind::PinnedConsumer
- | BenchmarkKind::PinnedProducerAndConsumer => {
- matches!(
- k,
- BenchmarkKind::PinnedProducer
- | BenchmarkKind::PinnedConsumer
- | BenchmarkKind::PinnedProducerAndConsumer
- )
- }
- BenchmarkKind::BalancedProducer
- | BenchmarkKind::BalancedConsumerGroup
- | BenchmarkKind::BalancedProducerAndConsumerGroup => {
- matches!(
- k,
- BenchmarkKind::BalancedProducer
- | BenchmarkKind::BalancedConsumerGroup
- | BenchmarkKind::BalancedProducerAndConsumerGroup
- )
- }
- BenchmarkKind::EndToEndProducingConsumer
- | BenchmarkKind::EndToEndProducingConsumerGroup => {
- matches!(
- k,
- BenchmarkKind::EndToEndProducingConsumer
- | BenchmarkKind::EndToEndProducingConsumerGroup
- )
- }
- })
+ .filter(|k| matches_tab(&props.kind, k))
.cloned()
.collect();
let empty_vec = Vec::new();
- let current_benchmarks = benchmark_ctx
+ let all_benchmarks = benchmark_ctx
.state
.entries
.get(&selected_kind)
.unwrap_or(&empty_vec);
+ let current_benchmarks: Vec<_> = all_benchmarks
+ .iter()
+ .filter(|b| filters.matches(b))
+ .collect();
+ let hidden_by_filter = all_benchmarks.len() - current_benchmarks.len();
let on_benchmark_select = {
let dispatch = benchmark_ctx.dispatch.clone();
let entries = benchmark_ctx.state.entries.clone();
+ let ui_state = ui_state.clone();
Callback::from(move |pretty_name: String| {
- let selected_benchmark = entries.get(&selected_kind).and_then(|benchmarks| {
+ let selected = entries.get(&selected_kind).and_then(|benchmarks| {
benchmarks
.iter()
.find(|b| b.params.pretty_name == pretty_name)
});
+ ui_state.dispatch(UiAction::SetLanding(false));
dispatch.emit(BenchmarkAction::SelectBenchmark(Box::new(
- selected_benchmark.cloned(),
+ selected.cloned(),
)));
})
};
@@ -99,12 +88,17 @@ pub fn benchmark_selector(props: &BenchmarkSelectorProps) -> Html {
})
};
- let current_value = benchmark_ctx
- .state
- .selected_benchmark
- .as_ref()
- .map(|b| b.params.pretty_name.clone())
- .unwrap_or_default();
+ let on_toggle_pin = {
+ let ui_state = ui_state.clone();
+ Callback::from(move |benchmark: BenchmarkReportLight| {
+ let next = if ui_state.compare_pin.as_ref().map(|b| b.uuid) == Some(benchmark.uuid) {
+ None
+ } else {
+ Some(Box::new(benchmark))
+ };
+ ui_state.dispatch(UiAction::SetComparePin(next));
+ })
+ };
html! {
<div class="benchmark-select">
@@ -114,58 +108,133 @@ pub fn benchmark_selector(props: &BenchmarkSelectorProps) -> Html {
available_kinds={available_kinds}
/>
+ if current_benchmarks.is_empty() && hidden_by_filter > 0 {
+ <div class="no-search-results">
+ <p>{format!("All {hidden_by_filter} benchmark(s) hidden by active filters.")}</p>
+ </div>
+ }
<div class="benchmark-list">
- {current_benchmarks.iter().map(|benchmark| {
- let pretty_name = benchmark.params.pretty_name.clone();
- let is_active = pretty_name == current_value;
- let on_click = {
- let on_benchmark_select = on_benchmark_select.clone();
- let pretty_name = pretty_name.clone();
- Callback::from(move |_| {
- on_benchmark_select.emit(pretty_name.clone());
- })
- };
- let pretty_name = pretty_name.split("(").next().unwrap().to_string();
-
- html! {
- <div
- class={classes!(
- "benchmark-list-item",
- is_active.then_some("active")
- )}
- onclick={on_click}
- >
- <div class="benchmark-list-item-content">
- <div class="benchmark-list-item-title">
- <span class="benchmark-list-item-dot" />
- {pretty_name}
- </div>
-
- <div class="benchmark-list-item-details">
- {if let Some(remark) = benchmark.params.remark.as_deref() {
- if !remark.is_empty() {
- let truncated_remark = if remark.len() > 30 {
- format!("{}..", &remark[0..28])
- } else {
- remark.to_string()
- };
- html! {
- <div class="benchmark-list-item-subtitle">
- <span class="benchmark-list-item-label">{"Remark:"}</span>
- <span>{truncated_remark}</span>
- </div>
- }
- } else {
- html! {}
- }
- } else {
- html! {}
- }}
- </div>
- </div>
- </div>
- }
- }).collect::<Html>()}
+ { for current_benchmarks.iter().map(|benchmark| render_item(
+ benchmark,
+ selected_uuid,
+ pinned_uuid,
+ &on_benchmark_select,
+ &on_toggle_pin,
+ ))}
+ </div>
+ </div>
+ }
+}
+
+fn matches_tab(tab_kind: &BenchmarkKind, candidate: &BenchmarkKind) -> bool {
+ use BenchmarkKind::*;
+ match tab_kind {
+ PinnedProducer | PinnedConsumer | PinnedProducerAndConsumer => matches!(
+ candidate,
+ PinnedProducer | PinnedConsumer | PinnedProducerAndConsumer
+ ),
+ BalancedProducer | BalancedConsumerGroup | BalancedProducerAndConsumerGroup => matches!(
+ candidate,
+ BalancedProducer | BalancedConsumerGroup | BalancedProducerAndConsumerGroup
+ ),
+ EndToEndProducingConsumer | EndToEndProducingConsumerGroup => matches!(
+ candidate,
+ EndToEndProducingConsumer | EndToEndProducingConsumerGroup
+ ),
+ }
+}
+
+fn render_item(
+ benchmark: &BenchmarkReportLight,
+ selected_uuid: Option<Uuid>,
+ pinned_uuid: Option<Uuid>,
+ on_select: &Callback<String>,
+ on_toggle_pin: &Callback<BenchmarkReportLight>,
+) -> Html {
+ let is_active = Some(benchmark.uuid) == selected_uuid;
+ let is_pinned = Some(benchmark.uuid) == pinned_uuid;
+ let full_name = benchmark.params.pretty_name.clone();
+ let display_name = full_name
+ .split('(')
+ .next()
+ .unwrap_or(&full_name)
+ .to_string();
+
+ let on_click = {
+ let on_select = on_select.clone();
+ let name = full_name.clone();
+ Callback::from(move |_| on_select.emit(name.clone()))
+ };
+
+ let on_pin_click = {
+ let on_toggle_pin = on_toggle_pin.clone();
+ let benchmark = benchmark.clone();
+ Callback::from(move |e: MouseEvent| {
+ e.stop_propagation();
+ on_toggle_pin.emit(benchmark.clone());
+ })
+ };
+
+ let pin_title = if is_pinned {
+ "Unpin from compare"
+ } else {
+ "Pin for compare"
+ };
+
+ html! {
+ <div
+ class={classes!(
+ "benchmark-list-item",
+ is_active.then_some("active"),
+ is_pinned.then_some("pinned"),
+ )}
+ onclick={on_click}
+ >
+ <div class="benchmark-list-item-content">
+ <div class="benchmark-list-item-title-row">
+ <div class="benchmark-list-item-title">
+ <span class="benchmark-list-item-dot" />
+ {display_name}
+ </div>
+ <button
+ type="button"
+ class={classes!("compare-pin-btn", is_pinned.then_some("active"))}
+ onclick={on_pin_click}
+ title={pin_title}
+ aria-label={pin_title}
+ >
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
+ fill={if is_pinned { "currentColor" } else { "none" }}
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M12 17v5" />
+ <path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
+ </svg>
+ </button>
+ </div>
+
+ { render_remark(&benchmark.params.remark) }
+ </div>
+ </div>
+ }
+}
+
+fn render_remark(remark: &Option<String>) -> Html {
+ let Some(remark) = remark.as_deref() else {
+ return html! {};
+ };
+ if remark.is_empty() {
+ return html! {};
+ }
+ let truncated = if remark.len() > 30 {
+ format!("{}..", &remark[..28])
+ } else {
+ remark.to_string()
+ };
+ html! {
+ <div class="benchmark-list-item-details">
+ <div class="benchmark-list-item-subtitle">
+ <span class="benchmark-list-item-label">{"Remark:"}</span>
+ <span>{truncated}</span>
</div>
</div>
}
diff --git a/core/bench/dashboard/frontend/src/components/selectors/measurement_type_selector.rs b/core/bench/dashboard/frontend/src/components/selectors/measurement_type_selector.rs
index d8023ed93..ca5482829 100644
--- a/core/bench/dashboard/frontend/src/components/selectors/measurement_type_selector.rs
+++ b/core/bench/dashboard/frontend/src/components/selectors/measurement_type_selector.rs
@@ -24,6 +24,7 @@ pub enum MeasurementType {
Latency,
Throughput,
Distribution,
+ Tail,
}
impl Display for MeasurementType {
@@ -32,6 +33,7 @@ impl Display for MeasurementType {
MeasurementType::Latency => write!(f, "Latency"),
MeasurementType::Throughput => write!(f, "Throughput"),
MeasurementType::Distribution => write!(f, "Distribution"),
+ MeasurementType::Tail => write!(f, "Tail"),
}
}
}
@@ -44,6 +46,7 @@ impl FromStr for MeasurementType {
"Latency" => Ok(MeasurementType::Latency),
"Throughput" => Ok(MeasurementType::Throughput),
"Distribution" => Ok(MeasurementType::Distribution),
+ "Tail" => Ok(MeasurementType::Tail),
_ => Err(()),
}
}
@@ -82,6 +85,12 @@ pub fn measurement_type_selector(props: &MeasurementTypeSelectorProps) -> Html {
>
{"Distribution"}
</button>
+ <button
+ class={if *selected == MeasurementType::Tail { "segment active" } else { "segment" }}
+ onclick={props.on_measurement_select.reform(|_| MeasurementType::Tail)}
+ >
+ {"Tail"}
+ </button>
</div>
</div>
}
diff --git a/core/bench/dashboard/frontend/src/components/selectors/mod.rs b/core/bench/dashboard/frontend/src/components/selectors/mod.rs
index 04fc93b63..947c21bb7 100644
--- a/core/bench/dashboard/frontend/src/components/selectors/mod.rs
+++ b/core/bench/dashboard/frontend/src/components/selectors/mod.rs
@@ -21,5 +21,6 @@ pub mod benchmark_selector;
pub mod gitref_selector;
pub mod hardware_selector;
pub mod measurement_type_selector;
+pub mod param_filters_panel;
pub mod recent_benchmarks_selector;
pub mod view_mode_selector;
diff --git a/core/bench/dashboard/frontend/src/components/selectors/param_filters_panel.rs b/core/bench/dashboard/frontend/src/components/selectors/param_filters_panel.rs
new file mode 100644
index 000000000..df5f14edf
--- /dev/null
+++ b/core/bench/dashboard/frontend/src/components/selectors/param_filters_panel.rs
@@ -0,0 +1,211 @@
+// 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::ui::{MetricField, MetricRange, ParamField, ParamRange, UiAction, use_ui};
+use yew::prelude::*;
+
+#[function_component(ParamFiltersPanel)]
+pub fn param_filters_panel() -> Html {
+ let ui = use_ui();
+ let is_open = use_state(|| false);
+
+ let toggle = {
+ let is_open = is_open.clone();
+ Callback::from(move |_| is_open.set(!*is_open))
+ };
+
+ let on_clear = {
+ let ui = ui.clone();
+ Callback::from(move |e: MouseEvent| {
+ e.stop_propagation();
+ ui.dispatch(UiAction::ClearParamFilters);
+ })
+ };
+
+ let filters = ui.param_filters.clone();
+ let active = filters.active_count();
+ let chevron = if *is_open { "v" } else { ">" };
+
+ html! {
+ <div class="param-filters-container">
+ <button type="button" class="param-filters-header" onclick={toggle}>
+ <span class="param-filters-chevron">{chevron}</span>
+ <span class="param-filters-title">{"Filters"}</span>
+ if active > 0 {
+ <span class="param-filters-badge">{active}</span>
+ }
+ </button>
+ if *is_open {
+ <div class="param-filters-body">
+ <ParamFilterRow label="Producers" field={ParamField::Producers}
+ range={filters.producers.clone()} />
+ <ParamFilterRow label="Consumers" field={ParamField::Consumers}
+ range={filters.consumers.clone()} />
+ <ParamFilterRow label="Streams" field={ParamField::Streams}
+ range={filters.streams.clone()} />
+ <ParamFilterRow label="Partitions" field={ParamField::Partitions}
+ range={filters.partitions.clone()} />
+ <ParamFilterRow label="Cons. Groups" field={ParamField::ConsumerGroups}
+ range={filters.consumer_groups.clone()} />
+ <MetricFilterRow label="Tput (MB/s)" field={MetricField::ThroughputMbS}
+ range={filters.throughput_mb_s.clone()} step="1" />
+ <MetricFilterRow label="P99 (ms)" field={MetricField::P99LatencyMs}
+ range={filters.p99_latency_ms.clone()} step="0.01" />
+ if active > 0 {
+ <button type="button" class="param-filters-clear" onclick={on_clear}>
+ {"Clear all"}
+ </button>
+ }
+ </div>
+ }
+ </div>
+ }
+}
+
+#[derive(Properties, PartialEq)]
+struct ParamFilterRowProps {
+ label: AttrValue,
+ field: ParamField,
+ range: ParamRange,
+}
+
+#[function_component(ParamFilterRow)]
+fn param_filter_row(props: &ParamFilterRowProps) -> Html {
+ let ui = use_ui();
+ let field = props.field;
+ let range = props.range.clone();
+
+ let on_from = {
+ let ui = ui.clone();
+ let range = range.clone();
+ Callback::from(move |e: InputEvent| {
+ let input: web_sys::HtmlInputElement = e.target_unchecked_into();
+ let next = ParamRange {
+ from: parse_u32(&input.value()),
+ to: range.to,
+ };
+ ui.dispatch(UiAction::SetParamRange(field, next));
+ })
+ };
+
+ let on_to = {
+ let ui = ui.clone();
+ let range = range.clone();
+ Callback::from(move |e: InputEvent| {
+ let input: web_sys::HtmlInputElement = e.target_unchecked_into();
+ let next = ParamRange {
+ from: range.from,
+ to: parse_u32(&input.value()),
+ };
+ ui.dispatch(UiAction::SetParamRange(field, next));
+ })
+ };
+
+ let from_value = range.from.map(|v| v.to_string()).unwrap_or_default();
+ let to_value = range.to.map(|v| v.to_string()).unwrap_or_default();
+
+ html! {
+ <div class="param-filter-row">
+ <label class="param-filter-label">{&props.label}</label>
+ <input type="number" min="0" step="1" class="param-filter-input"
+ placeholder="min" value={from_value} oninput={on_from} />
+ <span class="param-filter-sep">{"-"}</span>
+ <input type="number" min="0" step="1" class="param-filter-input"
+ placeholder="max" value={to_value} oninput={on_to} />
+ </div>
+ }
+}
+
+#[derive(Properties, PartialEq)]
+struct MetricFilterRowProps {
+ label: AttrValue,
+ field: MetricField,
+ range: MetricRange,
+ step: AttrValue,
+}
+
+#[function_component(MetricFilterRow)]
+fn metric_filter_row(props: &MetricFilterRowProps) -> Html {
+ let ui = use_ui();
+ let field = props.field;
+ let range = props.range.clone();
+
+ let on_from = {
+ let ui = ui.clone();
+ let range = range.clone();
+ Callback::from(move |e: InputEvent| {
+ let input: web_sys::HtmlInputElement = e.target_unchecked_into();
+ let next = MetricRange {
+ from: parse_f64(&input.value()),
+ to: range.to,
+ };
+ ui.dispatch(UiAction::SetMetricRange(field, next));
+ })
+ };
+
+ let on_to = {
+ let ui = ui.clone();
+ let range = range.clone();
+ Callback::from(move |e: InputEvent| {
+ let input: web_sys::HtmlInputElement = e.target_unchecked_into();
+ let next = MetricRange {
+ from: range.from,
+ to: parse_f64(&input.value()),
+ };
+ ui.dispatch(UiAction::SetMetricRange(field, next));
+ })
+ };
+
+ let from_value = range.from.map(format_f64).unwrap_or_default();
+ let to_value = range.to.map(format_f64).unwrap_or_default();
+
+ html! {
+ <div class="param-filter-row">
+ <label class="param-filter-label">{&props.label}</label>
+ <input type="number" min="0" step={props.step.clone()} class="param-filter-input"
+ placeholder="min" value={from_value} oninput={on_from} />
+ <span class="param-filter-sep">{"-"}</span>
+ <input type="number" min="0" step={props.step.clone()} class="param-filter-input"
+ placeholder="max" value={to_value} oninput={on_to} />
+ </div>
+ }
+}
+
+fn parse_u32(raw: &str) -> Option<u32> {
+ let trimmed = raw.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ trimmed.parse().ok()
+ }
+}
+
+fn parse_f64(raw: &str) -> Option<f64> {
+ let trimmed = raw.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ trimmed.parse().ok().filter(|v: &f64| v.is_finite())
+}
+
+fn format_f64(v: f64) -> String {
+ if v.fract() == 0.0 {
+ format!("{v:.0}")
+ } else {
+ format!("{v}")
+ }
+}
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
index 240b1fc70..027d67f78 100644
--- a/core/bench/dashboard/frontend/src/components/selectors/recent_benchmarks_selector.rs
+++ b/core/bench/dashboard/frontend/src/components/selectors/recent_benchmarks_selector.rs
@@ -18,6 +18,7 @@
use crate::{
api,
state::benchmark::{BenchmarkAction, use_benchmark},
+ state::ui::{UiAction, use_ui},
};
use bench_dashboard_shared::BenchmarkReportLight;
use chrono::{DateTime, Utc};
@@ -56,6 +57,9 @@ pub struct RecentBenchmarksSelectorProps {
#[function_component(RecentBenchmarksSelector)]
pub fn recent_benchmarks_selector(props: &RecentBenchmarksSelectorProps) -> Html {
let benchmark_ctx = use_benchmark();
+ let ui_state = use_ui();
+ let filters = ui_state.param_filters.clone();
+ let pinned_uuid = ui_state.compare_pin.as_ref().map(|b| b.uuid);
let recent_benchmarks = use_state(Vec::<BenchmarkReportLight>::new);
let is_loading = use_state(|| true);
@@ -64,23 +68,31 @@ pub fn recent_benchmarks_selector(props: &RecentBenchmarksSelectorProps) -> Html
let recent_benchmarks = recent_benchmarks.clone();
let is_loading = is_loading.clone();
let limit = props.limit;
+ let benchmark_dispatch = benchmark_ctx.dispatch.clone();
+ let ui_dispatch_handle = ui_state.clone();
use_effect_with((), move |_| {
spawn_local(async move {
match api::fetch_recent_benchmarks(Some(limit)).await {
Ok(mut data) => {
- data.sort_by(|a, b| {
- let parse_a = DateTime::parse_from_rfc3339(&a.timestamp);
- let parse_b = DateTime::parse_from_rfc3339(&b.timestamp);
- match (parse_a, parse_b) {
- (Ok(time_a), Ok(time_b)) => time_b.cmp(&time_a),
+ data.sort_by(|left, right| {
+ let parse_left = DateTime::parse_from_rfc3339(&left.timestamp);
+ let parse_right = DateTime::parse_from_rfc3339(&right.timestamp);
+ match (parse_left, parse_right) {
+ (Ok(time_left), Ok(time_right)) => time_right.cmp(&time_left),
_ => std::cmp::Ordering::Equal,
}
});
+ if let Some(most_recent) = data.first().cloned() {
+ ui_dispatch_handle.dispatch(UiAction::SetLanding(false));
+ benchmark_dispatch.emit(BenchmarkAction::SelectBenchmark(Box::new(
+ Some(most_recent),
+ )));
+ }
recent_benchmarks.set(data);
}
- Err(e) => {
- log!(format!("Error fetching recent benchmarks: {}", e));
+ Err(error) => {
+ log!(format!("Error fetching recent benchmarks: {}", error));
}
}
is_loading.set(false);
@@ -90,15 +102,30 @@ pub fn recent_benchmarks_selector(props: &RecentBenchmarksSelectorProps) -> Html
let on_benchmark_select = {
let benchmark_ctx = benchmark_ctx.clone();
+ let ui_state = ui_state.clone();
Callback::from(move |benchmark: BenchmarkReportLight| {
+ ui_state.dispatch(UiAction::SetLanding(false));
benchmark_ctx
.dispatch
.emit(BenchmarkAction::SelectBenchmark(Box::new(Some(benchmark))));
})
};
+ let on_toggle_pin = {
+ let ui_state = ui_state.clone();
+ Callback::from(move |benchmark: BenchmarkReportLight| {
+ let next = if ui_state.compare_pin.as_ref().map(|b| b.uuid) == Some(benchmark.uuid) {
+ None
+ } else {
+ Some(Box::new(benchmark))
+ };
+ ui_state.dispatch(UiAction::SetComparePin(next));
+ })
+ };
+
let filtered_benchmarks = (*recent_benchmarks)
.iter()
+ .filter(|benchmark| filters.matches(benchmark))
.filter(|benchmark| {
if props.search_query.is_empty() {
return true;
@@ -151,7 +178,11 @@ pub fn recent_benchmarks_selector(props: &RecentBenchmarksSelectorProps) -> Html
<p class="loading-message">{"Loading recent benchmarks..."}</p>
} else if filtered_benchmarks.is_empty() {
<div class="no-search-results">
- <p>{format!("No benchmarks found matching \"{}\"", props.search_query)}</p>
+ { if props.search_query.is_empty() && filters.is_any_active() {
+ html! { <p>{"No benchmarks match the active filters."}</p> }
+ } else {
+ html! { <p>{format!("No benchmarks found matching \"{}\"", props.search_query)}</p> }
+ }}
</div>
} else {
<ul class="benchmark-list">
@@ -163,14 +194,29 @@ pub fn recent_benchmarks_selector(props: &RecentBenchmarksSelectorProps) -> Html
on_benchmark_select.emit(benchmark_clone.clone());
})
};
+ let on_pin_click = {
+ let on_toggle_pin = on_toggle_pin.clone();
+ let benchmark_clone = benchmark.clone();
+ Callback::from(move |e: MouseEvent| {
+ e.stop_propagation();
+ on_toggle_pin.emit(benchmark_clone.clone());
+ })
+ };
let timestamp_display = format_relative_time(&benchmark.timestamp);
let is_selected = benchmark_ctx.state.selected_benchmark.as_ref()
.map(|selected| selected.uuid == benchmark.uuid)
.unwrap_or(false);
+ let is_pinned = Some(benchmark.uuid) == pinned_uuid;
+ let pin_title = if is_pinned { "Unpin from compare" } else { "Pin for compare" };
html! {
- <li class={classes!("benchmark-list-item", "recent-benchmark-item", is_selected.then_some("active"))} onclick={on_select}>
+ <li class={classes!(
+ "benchmark-list-item",
+ "recent-benchmark-item",
+ is_selected.then_some("active"),
+ is_pinned.then_some("pinned"),
+ )} onclick={on_select}>
<div class="benchmark-list-item-content">
<div class="benchmark-list-item-header">
<div class="benchmark-list-item-title">
@@ -178,6 +224,20 @@ pub fn recent_benchmarks_selector(props: &RecentBenchmarksSelectorProps) -> Html
{benchmark.params.benchmark_kind.to_string()}
</div>
<div class="benchmark-list-item-time">{timestamp_display}</div>
+ <button
+ type="button"
+ class={classes!("compare-pin-btn", is_pinned.then_some("active"))}
+ onclick={on_pin_click}
+ title={pin_title}
+ aria-label={pin_title}
+ >
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
+ fill={if is_pinned { "currentColor" } else { "none" }}
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M12 17v5" />
+ <path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
+ </svg>
+ </button>
</div>
<div class="benchmark-list-item-details">
diff --git a/core/bench/dashboard/frontend/src/components/theme/mod.rs b/core/bench/dashboard/frontend/src/components/theme/mod.rs
index ab0d535aa..d9d031fcc 100644
--- a/core/bench/dashboard/frontend/src/components/theme/mod.rs
+++ b/core/bench/dashboard/frontend/src/components/theme/mod.rs
@@ -16,4 +16,3 @@
// under the License.
pub mod theme_provider;
-pub mod theme_toggle;
diff --git a/core/bench/dashboard/frontend/src/components/theme/theme_toggle.rs b/core/bench/dashboard/frontend/src/components/theme/theme_toggle.rs
deleted file mode 100644
index 19bd0a601..000000000
--- a/core/bench/dashboard/frontend/src/components/theme/theme_toggle.rs
+++ /dev/null
@@ -1,57 +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 yew::prelude::*;
-
-#[derive(Properties, PartialEq)]
-pub struct ThemeToggleProps {
- pub on_toggle: Callback<bool>,
- pub is_dark: bool,
-}
-
-#[function_component(ThemeToggle)]
-pub fn theme_toggle(props: &ThemeToggleProps) -> Html {
- let onclick = {
- let on_toggle = props.on_toggle.clone();
- let is_dark = props.is_dark;
- Callback::from(move |_| {
- on_toggle.emit(!is_dark);
- })
- };
-
- html! {
- <button class="theme-toggle" {onclick}>
- if props.is_dark {
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
- </svg>
- } else {
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <circle cx="12" cy="12" r="5"></circle>
- <line x1="12" y1="1" x2="12" y2="3"></line>
- <line x1="12" y1="21" x2="12" y2="23"></line>
- <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
- <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
- <line x1="1" y1="12" x2="3" y2="12"></line>
- <line x1="21" y1="12" x2="23" y2="12"></line>
- <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
- <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
- </svg>
- }
- </button>
- }
-}
diff --git a/core/bench/dashboard/frontend/src/state/benchmark.rs b/core/bench/dashboard/frontend/src/state/benchmark.rs
index 5c7c0f15f..b11c85596 100644
--- a/core/bench/dashboard/frontend/src/state/benchmark.rs
+++ b/core/bench/dashboard/frontend/src/state/benchmark.rs
@@ -20,6 +20,7 @@ use bench_report::{
benchmark_kind::BenchmarkKind, numeric_parameter::BenchmarkNumericParameter,
transport::BenchmarkTransport,
};
+use chrono::{DateTime, Duration};
use gloo::console::log;
use std::{collections::BTreeMap, rc::Rc};
use yew::prelude::*;
@@ -224,7 +225,7 @@ impl BenchmarkState {
self.current_hardware, hardware, self.current_gitref, gitref_for_entries
));
- let best = Self::find_best_benchmark(&entries);
+ let best = self.find_best_benchmark(&entries);
if let Some(best) = best {
new_selected_benchmark = Some(best.clone());
new_selected_kind = best.params.benchmark_kind;
@@ -332,23 +333,72 @@ impl BenchmarkState {
}
}
+ /// Pick a sensible default benchmark from the freshest run batch.
+ ///
+ /// Scopes to `self.selected_kind` when that kind has entries (keeps the default
+ /// consistent with the active sidebar tab); otherwise falls back to all entries.
fn find_best_benchmark(
- benchmarks: &BTreeMap<BenchmarkKind, Vec<BenchmarkReportLight>>,
+ &self,
+ entries: &BTreeMap<BenchmarkKind, Vec<BenchmarkReportLight>>,
) -> Option<BenchmarkReportLight> {
- benchmarks
- .values()
+ let in_kind: Vec<&BenchmarkReportLight> = entries
+ .get(&self.selected_kind)
+ .into_iter()
.flatten()
- .max_by_key(|bm| {
- bm.group_metrics
- .first()
- .unwrap()
- .summary
- .average_p99_latency_ms as u64
- })
- .cloned()
+ .collect();
+ let scoped: Vec<&BenchmarkReportLight> = if in_kind.is_empty() {
+ entries.values().flatten().collect()
+ } else {
+ in_kind
+ };
+ pick_best_from_recent_batch(&scoped)
}
}
+/// 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.
+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);
+
+ 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| {
+ (
+ group.summary.average_p99_latency_ms,
+ group.summary.total_throughput_megabytes_per_second,
+ )
+ })
+ };
+ 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));
+ left_p99
+ .partial_cmp(&right_p99)
+ .unwrap_or(std::cmp::Ordering::Equal)
+ .then(
+ right_throughput
+ .partial_cmp(&left_throughput)
+ .unwrap_or(std::cmp::Ordering::Equal),
+ )
+ })
+ .cloned()
+}
+
/// Actions that can be performed on the benchmark state
#[derive(Debug)]
pub enum BenchmarkAction {
diff --git a/core/bench/dashboard/frontend/src/state/ui.rs b/core/bench/dashboard/frontend/src/state/ui.rs
index 57c0146d1..a1359b533 100644
--- a/core/bench/dashboard/frontend/src/state/ui.rs
+++ b/core/bench/dashboard/frontend/src/state/ui.rs
@@ -16,15 +16,117 @@
// under the License.
use crate::components::selectors::measurement_type_selector::MeasurementType;
+use bench_dashboard_shared::BenchmarkReportLight;
use std::rc::Rc;
use yew::prelude::*;
#[derive(Clone, Debug, PartialEq)]
#[allow(dead_code)]
pub enum ViewMode {
- SingleGitref, // View detailed performance for a specific gitref
- GitrefTrend, // View performance trends across all gitrefs
- RecentBenchmarks, // View recently added benchmarks
+ SingleGitref,
+ GitrefTrend,
+ RecentBenchmarks,
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct ParamRange {
+ pub from: Option<u32>,
+ pub to: Option<u32>,
+}
+
+impl ParamRange {
+ pub fn is_active(&self) -> bool {
+ self.from.is_some() || self.to.is_some()
+ }
+
+ pub fn matches(&self, value: u32) -> bool {
+ self.from.is_none_or(|f| value >= f) && self.to.is_none_or(|t| value <= t)
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct MetricRange {
+ pub from: Option<f64>,
+ pub to: Option<f64>,
+}
+
+impl MetricRange {
+ pub fn is_active(&self) -> bool {
+ self.from.is_some() || self.to.is_some()
+ }
+
+ pub fn matches(&self, value: f64) -> bool {
+ self.from.is_none_or(|f| value >= f) && self.to.is_none_or(|t| value <= t)
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct ParamFilters {
+ pub producers: ParamRange,
+ pub consumers: ParamRange,
+ pub streams: ParamRange,
+ pub partitions: ParamRange,
+ pub consumer_groups: ParamRange,
+ pub throughput_mb_s: MetricRange,
+ pub p99_latency_ms: MetricRange,
+}
+
+impl ParamFilters {
+ pub fn active_count(&self) -> usize {
+ let u32_active = [
+ &self.producers,
+ &self.consumers,
+ &self.streams,
+ &self.partitions,
+ &self.consumer_groups,
+ ]
+ .iter()
+ .filter(|r| r.is_active())
+ .count();
+ let metric_active = [&self.throughput_mb_s, &self.p99_latency_ms]
+ .iter()
+ .filter(|r| r.is_active())
+ .count();
+ u32_active + metric_active
+ }
+
+ pub fn is_any_active(&self) -> bool {
+ self.active_count() > 0
+ }
+
+ pub fn matches(&self, benchmark: &BenchmarkReportLight) -> bool {
+ let p = &benchmark.params;
+ let params_ok = self.producers.matches(p.producers)
+ && self.consumers.matches(p.consumers)
+ && self.streams.matches(p.streams)
+ && self.partitions.matches(p.partitions)
+ && self.consumer_groups.matches(p.consumer_groups);
+ if !params_ok {
+ return false;
+ }
+ let metrics_active = self.throughput_mb_s.is_active() || self.p99_latency_ms.is_active();
+ let Some(summary) = benchmark.group_metrics.first().map(|m| &m.summary) else {
+ return !metrics_active;
+ };
+ self.throughput_mb_s
+ .matches(summary.total_throughput_megabytes_per_second)
+ && self.p99_latency_ms.matches(summary.average_p99_latency_ms)
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ParamField {
+ Producers,
+ Consumers,
+ Streams,
+ Partitions,
+ ConsumerGroups,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MetricField {
+ ThroughputMbS,
+ P99LatencyMs,
}
#[derive(Clone, Debug, PartialEq)]
@@ -34,6 +136,12 @@ pub struct UiState {
pub is_benchmark_tooltip_visible: bool,
pub is_server_stats_tooltip_visible: bool,
pub is_embed_modal_visible: bool,
+ pub param_filters: ParamFilters,
+ pub is_sidebar_collapsed: bool,
+ pub compare_pin: Option<Box<BenchmarkReportLight>>,
+ /// Overrides chart rendering to keep the hero landing on screen until the user picks a benchmark.
+ /// Reducer never mutates selected_benchmark for this; the flag only gates rendering in MainContent.
+ pub is_landing: bool,
}
impl Default for UiState {
@@ -44,6 +152,10 @@ impl Default for UiState {
is_benchmark_tooltip_visible: false,
is_server_stats_tooltip_visible: false,
is_embed_modal_visible: false,
+ param_filters: ParamFilters::default(),
+ is_sidebar_collapsed: false,
+ compare_pin: None,
+ is_landing: true,
}
}
}
@@ -54,6 +166,12 @@ pub enum UiAction {
ToggleServerStatsTooltip,
ToggleEmbedModal,
SetViewMode(ViewMode),
+ SetParamRange(ParamField, ParamRange),
+ SetMetricRange(MetricField, MetricRange),
+ ClearParamFilters,
+ ToggleSidebar,
+ SetComparePin(Option<Box<BenchmarkReportLight>>),
+ SetLanding(bool),
}
impl Reducible for UiState {
@@ -81,6 +199,47 @@ impl Reducible for UiState {
view_mode: vm,
..(*self).clone()
},
+ UiAction::SetParamRange(field, range) => {
+ let mut filters = self.param_filters.clone();
+ match field {
+ ParamField::Producers => filters.producers = range,
+ ParamField::Consumers => filters.consumers = range,
+ ParamField::Streams => filters.streams = range,
+ ParamField::Partitions => filters.partitions = range,
+ ParamField::ConsumerGroups => filters.consumer_groups = range,
+ }
+ UiState {
+ param_filters: filters,
+ ..(*self).clone()
+ }
+ }
+ UiAction::SetMetricRange(field, range) => {
+ let mut filters = self.param_filters.clone();
+ match field {
+ MetricField::ThroughputMbS => filters.throughput_mb_s = range,
+ MetricField::P99LatencyMs => filters.p99_latency_ms = range,
+ }
+ UiState {
+ param_filters: filters,
+ ..(*self).clone()
+ }
+ }
+ UiAction::ClearParamFilters => UiState {
+ param_filters: ParamFilters::default(),
+ ..(*self).clone()
+ },
+ UiAction::ToggleSidebar => UiState {
+ is_sidebar_collapsed: !self.is_sidebar_collapsed,
+ ..(*self).clone()
+ },
+ UiAction::SetComparePin(pin) => UiState {
+ compare_pin: pin,
+ ..(*self).clone()
+ },
+ UiAction::SetLanding(is_landing) => UiState {
+ is_landing,
+ ..(*self).clone()
+ },
};
next.into()
}
diff --git a/scripts/dashboard/run_dev.sh b/scripts/dashboard/run_dev.sh
index 02606bfb3..5fb8c83ac 100755
--- a/scripts/dashboard/run_dev.sh
+++ b/scripts/dashboard/run_dev.sh
@@ -20,10 +20,11 @@
# Exit on any error
set -euo pipefail
-# Check if performance_results directory exists
-if [ ! -d "${PWD}/performance_results" ]; then
- echo "Error: 'performance_results' directory does not exist."
- echo "Please run benchmarks with \`output\` subcommand first to generate performance results."
+RESULTS_DIR="${RESULTS_DIR:-${PWD}/performance_results}"
+
+if [ ! -d "$RESULTS_DIR" ]; then
+ echo "Error: results directory does not exist: $RESULTS_DIR"
+ echo "Set RESULTS_DIR=/path/to/performance_results or run benchmarks with \`output\` subcommand first."
exit 1
fi
@@ -56,8 +57,8 @@ if [ $# -gt 0 ]; then
fi
# Start server in background
-echo "Starting server..."
-cargo run --bin iggy-bench-dashboard-server &
+echo "Starting server (results: $RESULTS_DIR)..."
+cargo run --bin iggy-bench-dashboard-server -- --results-dir "$RESULTS_DIR" &
# Wait a bit for server to start
sleep 1