This is an automated email from the ASF dual-hosted git repository.

hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 2fdd6e84a feat(bench): record cluster topology in reports and surface 
it in UI (#3737)
2fdd6e84a is described below

commit 2fdd6e84a209285a6876181dc54b2f68267c4d1c
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Thu Jul 23 15:09:53 2026 +0200

    feat(bench): record cluster topology in reports and surface it in UI (#3737)
---
 Cargo.lock                                         |   2 +-
 core/bench/dashboard/frontend/assets/style.css     |  11 ++
 .../src/components/layout/benchmark_meta.rs        |   3 +
 .../frontend/src/components/layout/sidebar.rs      |  31 ++++-
 .../frontend/src/components/layout/sweep_view.rs   |  56 ++++++++
 .../src/components/selectors/benchmarks_list.rs    |   2 +
 .../components/selectors/dense_benchmark_row.rs    |   5 +
 core/bench/dashboard/frontend/src/state/ui.rs      |  68 ++++++++++
 core/bench/dashboard/shared/src/lib.rs             |   7 +-
 core/bench/dashboard/shared/src/subtext.rs         |  52 +++++++-
 core/bench/report/src/types/cluster.rs             | 125 ++++++++++++++++++
 core/bench/report/src/types/mod.rs                 |   1 +
 core/bench/report/src/types/report.rs              |  49 +++++++
 core/bench/src/analytics/report_builder.rs         | 147 ++++++++++++++++++++-
 core/bench/src/runner.rs                           |   5 +-
 core/server-ng/Cargo.toml                          |   2 +-
 16 files changed, 558 insertions(+), 8 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index ea71b9e89..4bc2420ba 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11814,7 +11814,7 @@ dependencies = [
 
 [[package]]
 name = "server-ng"
-version = "0.8.0"
+version = "0.9.0-edge.1"
 dependencies = [
  "ahash 0.8.12",
  "argon2",
diff --git a/core/bench/dashboard/frontend/assets/style.css 
b/core/bench/dashboard/frontend/assets/style.css
index 21d1681b8..25eb53aa3 100644
--- a/core/bench/dashboard/frontend/assets/style.css
+++ b/core/bench/dashboard/frontend/assets/style.css
@@ -1987,6 +1987,17 @@ body.dark .dense-row.active {
     opacity: 0.7;
 }
 
+.dense-row-cluster {
+    flex-shrink: 0;
+    padding: 0 5px;
+    border-radius: 4px;
+    background: rgba(56, 189, 248, 0.14);
+    color: #38bdf8;
+    font-size: 10px;
+    font-weight: 700;
+    letter-spacing: 0.03em;
+}
+
 .dense-row-compare-btn {
     display: inline-flex;
     align-items: center;
diff --git 
a/core/bench/dashboard/frontend/src/components/layout/benchmark_meta.rs 
b/core/bench/dashboard/frontend/src/components/layout/benchmark_meta.rs
index 91016bf2f..fa1dd8de2 100644
--- a/core/bench/dashboard/frontend/src/components/layout/benchmark_meta.rs
+++ b/core/bench/dashboard/frontend/src/components/layout/benchmark_meta.rs
@@ -71,6 +71,9 @@ fn render_config_row(benchmark: &BenchmarkReportLight) -> 
Html {
     let total_bytes = benchmark.total_bytes();
 
     let mut chips = vec![(actors_label(benchmark), actors_value(benchmark))];
+    if let Some(cluster) = &benchmark.cluster {
+        chips.insert(0, ("Topology", cluster.label()));
+    }
     if params.streams > 0 {
         chips.push(("Streams", params.streams.to_string()));
         // Benchmarks currently create 1 topic per stream; expose it so the
diff --git a/core/bench/dashboard/frontend/src/components/layout/sidebar.rs 
b/core/bench/dashboard/frontend/src/components/layout/sidebar.rs
index 72aba0a59..32898b0b7 100644
--- a/core/bench/dashboard/frontend/src/components/layout/sidebar.rs
+++ b/core/bench/dashboard/frontend/src/components/layout/sidebar.rs
@@ -20,7 +20,7 @@ use 
crate::components::selectors::benchmarks_list::BenchmarksList;
 use crate::components::selectors::param_filters_panel::ParamFiltersPanel;
 use crate::router::AppRoute;
 use crate::state::benchmark::{BenchmarkAction, recency_cmp, use_benchmark};
-use crate::state::ui::{KindGroup, SidebarSort, UiAction, use_ui};
+use crate::state::ui::{KindGroup, SidebarSort, TopologyFilter, UiAction, 
use_ui};
 use bench_dashboard_shared::BenchmarkReportLight;
 use gloo::console::log;
 use std::cell::Cell;
@@ -110,6 +110,13 @@ pub fn sidebar(_props: &SidebarProps) -> Html {
         Callback::from(move |group: KindGroup| 
ui.dispatch(UiAction::ToggleKindFilter(group)))
     };
 
+    let on_topology_select = {
+        let ui = ui.clone();
+        Callback::from(move |filter: TopologyFilter| {
+            ui.dispatch(UiAction::SetTopologyFilter(filter))
+        })
+    };
+
     let on_sort_change = {
         let ui = ui.clone();
         Callback::from(move |event: Event| {
@@ -149,6 +156,7 @@ pub fn sidebar(_props: &SidebarProps) -> Html {
     let hardware_options = collect_hardware(&benchmarks);
     let gitref_options = collect_gitrefs(&benchmarks, 
ui.hardware_filter.as_deref());
     let active_kind_filter = ui.sidebar_kind_filter.clone();
+    let active_topology = ui.topology_filter;
     let current_sort = ui.sidebar_sort;
     let current_hardware = ui.hardware_filter.clone().unwrap_or_default();
     let current_gitref = ui.gitref_filter.clone().unwrap_or_default();
@@ -239,6 +247,27 @@ pub fn sidebar(_props: &SidebarProps) -> Html {
                     })}
                 </div>
 
+                <div class="sidebar-kind-chips">
+                    { for TopologyFilter::all().iter().map(|filter| {
+                        let is_active = active_topology == *filter;
+                        let filter_copy = *filter;
+                        let on_click = {
+                            let on_topology_select = 
on_topology_select.clone();
+                            Callback::from(move |_: MouseEvent| 
on_topology_select.emit(filter_copy))
+                        };
+                        html! {
+                            <button
+                                type="button"
+                                class={classes!("sidebar-chip", 
is_active.then_some("active"))}
+                                onclick={on_click}
+                                aria-pressed={is_active.to_string()}
+                            >
+                                {filter.label()}
+                            </button>
+                        }
+                    })}
+                </div>
+
                 <label class="sidebar-sort">
                     <svg class="sidebar-sort-icon" 
xmlns="http://www.w3.org/2000/svg";
                          width="14" height="14" viewBox="0 0 24 24" fill="none"
diff --git a/core/bench/dashboard/frontend/src/components/layout/sweep_view.rs 
b/core/bench/dashboard/frontend/src/components/layout/sweep_view.rs
index 5e556d570..812479b5d 100644
--- a/core/bench/dashboard/frontend/src/components/layout/sweep_view.rs
+++ b/core/bench/dashboard/frontend/src/components/layout/sweep_view.rs
@@ -178,6 +178,13 @@ fn siblings_match(
     {
         return false;
     }
+    // A cluster run and a single-node run with otherwise equal params must not
+    // pool into one sweep line. Distinct node counts are distinct topologies.
+    if selected.cluster.as_ref().map(|c| c.nodes.len())
+        != candidate.cluster.as_ref().map(|c| c.nodes.len())
+    {
+        return false;
+    }
     SweepAxis::ALL
         .iter()
         .filter(|axis| **axis != varying_axis)
@@ -288,3 +295,52 @@ fn project(
     };
     (screen_x, screen_y)
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use bench_report::cluster::{BenchmarkClusterInfo, BenchmarkClusterNode};
+
+    fn cluster(nodes: usize) -> BenchmarkClusterInfo {
+        BenchmarkClusterInfo {
+            name: "vsr".to_string(),
+            nodes: (0..nodes)
+                .map(|index| BenchmarkClusterNode {
+                    name: format!("node-{index}"),
+                    role: "follower".to_string(),
+                    status: "healthy".to_string(),
+                })
+                .collect(),
+        }
+    }
+
+    #[test]
+    fn given_equal_params_when_cluster_size_differs_should_not_be_siblings() {
+        let single = BenchmarkReportLight::default();
+        let clustered = BenchmarkReportLight {
+            cluster: Some(cluster(3)),
+            ..Default::default()
+        };
+        assert!(!siblings_match(&single, &clustered, SweepAxis::Producers));
+    }
+
+    #[test]
+    fn given_equal_params_and_equal_cluster_size_should_be_siblings() {
+        let left = BenchmarkReportLight {
+            cluster: Some(cluster(3)),
+            ..Default::default()
+        };
+        let right = BenchmarkReportLight {
+            cluster: Some(cluster(3)),
+            ..Default::default()
+        };
+        assert!(siblings_match(&left, &right, SweepAxis::Producers));
+    }
+
+    #[test]
+    fn given_both_single_node_when_params_equal_should_be_siblings() {
+        let left = BenchmarkReportLight::default();
+        let right = BenchmarkReportLight::default();
+        assert!(siblings_match(&left, &right, SweepAxis::Producers));
+    }
+}
diff --git 
a/core/bench/dashboard/frontend/src/components/selectors/benchmarks_list.rs 
b/core/bench/dashboard/frontend/src/components/selectors/benchmarks_list.rs
index 84a9c625f..509645928 100644
--- a/core/bench/dashboard/frontend/src/components/selectors/benchmarks_list.rs
+++ b/core/bench/dashboard/frontend/src/components/selectors/benchmarks_list.rs
@@ -48,6 +48,7 @@ pub fn benchmarks_list(props: &BenchmarksListProps) -> Html {
     let param_filters = ui_state.param_filters.clone();
     let search = ui_state.sidebar_search.to_lowercase();
     let kind_filter = ui_state.sidebar_kind_filter.clone();
+    let topology_filter = ui_state.topology_filter;
     let hardware_filter = ui_state.hardware_filter.clone();
     let gitref_filter = ui_state.gitref_filter.clone();
     let sort = ui_state.sidebar_sort;
@@ -123,6 +124,7 @@ pub fn benchmarks_list(props: &BenchmarksListProps) -> Html 
{
         .iter()
         .filter(|benchmark| param_filters.matches(benchmark))
         .filter(|benchmark| kind_filter_matches(&kind_filter, 
benchmark.params.benchmark_kind))
+        .filter(|benchmark| topology_filter.matches(benchmark))
         .filter(|benchmark| hardware_matches(hardware_filter.as_deref(), 
benchmark))
         .filter(|benchmark| gitref_matches(gitref_filter.as_deref(), 
benchmark))
         .filter(|benchmark| search_matches(&search, benchmark))
diff --git 
a/core/bench/dashboard/frontend/src/components/selectors/dense_benchmark_row.rs 
b/core/bench/dashboard/frontend/src/components/selectors/dense_benchmark_row.rs
index e94bca615..b2e59b10f 100644
--- 
a/core/bench/dashboard/frontend/src/components/selectors/dense_benchmark_row.rs
+++ 
b/core/bench/dashboard/frontend/src/components/selectors/dense_benchmark_row.rs
@@ -86,6 +86,11 @@ pub fn dense_benchmark_row(props: &DenseBenchmarkRowProps) 
-> Html {
                 <div class="dense-row-body">
                     <div class="dense-row-title">{display_name}</div>
                     <div class="dense-row-meta">
+                        if let Some(cluster) = &benchmark.cluster {
+                            <span class="dense-row-cluster" 
title={cluster.label()}>
+                                { format!("{}N", cluster.nodes.len()) }
+                            </span>
+                        }
                         { render_metrics(benchmark) }
                         if props.show_timestamp {
                             <span class="dense-row-meta-sep">{"·"}</span>
diff --git a/core/bench/dashboard/frontend/src/state/ui.rs 
b/core/bench/dashboard/frontend/src/state/ui.rs
index c3b241380..84cc29f5e 100644
--- a/core/bench/dashboard/frontend/src/state/ui.rs
+++ b/core/bench/dashboard/frontend/src/state/ui.rs
@@ -191,6 +191,37 @@ impl KindGroup {
     }
 }
 
+/// Single-select topology filter. `None` cluster field means a single-node 
run.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum TopologyFilter {
+    #[default]
+    All,
+    SingleNode,
+    Cluster,
+}
+
+impl TopologyFilter {
+    pub fn label(self) -> &'static str {
+        match self {
+            Self::All => "All",
+            Self::SingleNode => "Single node",
+            Self::Cluster => "Cluster",
+        }
+    }
+
+    pub fn all() -> [Self; 3] {
+        [Self::All, Self::SingleNode, Self::Cluster]
+    }
+
+    pub fn matches(self, benchmark: &BenchmarkReportLight) -> bool {
+        match self {
+            Self::All => true,
+            Self::SingleNode => benchmark.cluster.is_none(),
+            Self::Cluster => benchmark.cluster.is_some(),
+        }
+    }
+}
+
 #[derive(Clone, Debug, PartialEq)]
 pub struct UiState {
     pub selected_measurement: MeasurementType,
@@ -203,6 +234,7 @@ pub struct UiState {
     pub sidebar_search: String,
     pub sidebar_sort: SidebarSort,
     pub sidebar_kind_filter: HashSet<KindGroup>,
+    pub topology_filter: TopologyFilter,
     pub hardware_filter: Option<String>,
     pub gitref_filter: Option<String>,
 }
@@ -220,6 +252,7 @@ impl Default for UiState {
             sidebar_search: String::new(),
             sidebar_sort: SidebarSort::default(),
             sidebar_kind_filter: HashSet::new(),
+            topology_filter: TopologyFilter::default(),
             hardware_filter: None,
             gitref_filter: None,
         }
@@ -245,6 +278,7 @@ pub enum UiAction {
     SetSidebarSearch(String),
     SetSidebarSort(SidebarSort),
     ToggleKindFilter(KindGroup),
+    SetTopologyFilter(TopologyFilter),
     SetHardwareFilter(Option<String>),
     SetGitrefFilter(Option<String>),
 }
@@ -349,6 +383,10 @@ impl Reducible for UiState {
                     ..(*self).clone()
                 }
             }
+            UiAction::SetTopologyFilter(filter) => UiState {
+                topology_filter: filter,
+                ..(*self).clone()
+            },
         };
         next.into()
     }
@@ -380,6 +418,7 @@ pub fn use_ui() -> UseReducerHandle<UiState> {
 mod tests {
     use super::*;
     use bench_dashboard_shared::BenchmarkGroupMetricsLight;
+    use bench_report::cluster::{BenchmarkClusterInfo, BenchmarkClusterNode};
     use bench_report::group_metrics_kind::GroupMetricsKind;
     use bench_report::group_metrics_summary::BenchmarkGroupMetricsSummary;
 
@@ -511,6 +550,22 @@ mod tests {
         assert!(!KindGroup::EndToEnd.matches(BenchmarkKind::BalancedProducer));
     }
 
+    #[test]
+    fn 
given_topology_filter_when_matching_should_split_single_node_from_cluster() {
+        let single = benchmark_with(1, 0, 1, 1, 0, 0.0, 0.0);
+        let mut clustered = benchmark_with(1, 0, 1, 1, 0, 0.0, 0.0);
+        clustered.cluster = Some(cluster_info(3));
+
+        assert!(TopologyFilter::All.matches(&single));
+        assert!(TopologyFilter::All.matches(&clustered));
+
+        assert!(TopologyFilter::SingleNode.matches(&single));
+        assert!(!TopologyFilter::SingleNode.matches(&clustered));
+
+        assert!(!TopologyFilter::Cluster.matches(&single));
+        assert!(TopologyFilter::Cluster.matches(&clustered));
+    }
+
     fn benchmark_with(
         producers: u32,
         consumers: u32,
@@ -536,6 +591,19 @@ mod tests {
         report
     }
 
+    fn cluster_info(nodes: usize) -> BenchmarkClusterInfo {
+        BenchmarkClusterInfo {
+            name: "vsr".to_string(),
+            nodes: (0..nodes)
+                .map(|index| BenchmarkClusterNode {
+                    name: format!("node-{index}"),
+                    role: if index == 0 { "leader" } else { "follower" 
}.to_string(),
+                    status: "healthy".to_string(),
+                })
+                .collect(),
+        }
+    }
+
     fn summary_with(throughput_mb_s: f64, p99_ms: f64) -> 
BenchmarkGroupMetricsSummary {
         BenchmarkGroupMetricsSummary {
             kind: GroupMetricsKind::Producers,
diff --git a/core/bench/dashboard/shared/src/lib.rs 
b/core/bench/dashboard/shared/src/lib.rs
index 7dd11cf4e..fcbca10f5 100644
--- a/core/bench/dashboard/shared/src/lib.rs
+++ b/core/bench/dashboard/shared/src/lib.rs
@@ -19,8 +19,8 @@ pub mod subtext;
 pub mod title;
 
 use bench_report::{
-    group_metrics_summary::BenchmarkGroupMetricsSummary, 
hardware::BenchmarkHardware,
-    individual_metrics_summary::BenchmarkIndividualMetricsSummary,
+    cluster::BenchmarkClusterInfo, 
group_metrics_summary::BenchmarkGroupMetricsSummary,
+    hardware::BenchmarkHardware, 
individual_metrics_summary::BenchmarkIndividualMetricsSummary,
     latency_distribution::LatencyDistribution, params::BenchmarkParams,
     server_stats::BenchmarkServerStats,
 };
@@ -34,6 +34,9 @@ pub struct BenchmarkReportLight {
     pub uuid: Uuid,
     pub server_stats: BenchmarkServerStats,
     pub params: BenchmarkParams,
+    /// Cluster topology, present only for cluster benchmarks (None = single 
node)
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub cluster: Option<BenchmarkClusterInfo>,
     pub hardware: BenchmarkHardware,
     pub group_metrics: Vec<BenchmarkGroupMetricsLight>,
     pub individual_metrics: Vec<BenchmarkIndividualMetricsLight>,
diff --git a/core/bench/dashboard/shared/src/subtext.rs 
b/core/bench/dashboard/shared/src/subtext.rs
index cd092b4ec..35c943e72 100644
--- a/core/bench/dashboard/shared/src/subtext.rs
+++ b/core/bench/dashboard/shared/src/subtext.rs
@@ -122,9 +122,13 @@ impl BenchmarkReportLight {
             format!("  •  {} Partitions per Topic", self.params.partitions)
         };
         let streams = format!("{} Streams", self.params.streams);
+        let cluster = match &self.cluster {
+            Some(info) => format!("  •  {}", info.label()),
+            None => String::new(),
+        };
 
         format!(
-            "{actors_info}  •  {streams}  •  {topics}{partitions}  •  
{messages_per_batch} Msg/batch  •  {message_batches} Batches  •  {message_size} 
Bytes/msg  •  {user_data_print}",
+            "{actors_info}  •  {streams}  •  {topics}{partitions}  •  
{messages_per_batch} Msg/batch  •  {message_batches} Batches  •  {message_size} 
Bytes/msg  •  {user_data_print}{cluster}",
         )
     }
 
@@ -218,3 +222,49 @@ impl BenchmarkGroupMetricsLight {
         )
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use bench_report::cluster::{BenchmarkClusterInfo, BenchmarkClusterNode};
+
+    fn node(name: &str, role: &str) -> BenchmarkClusterNode {
+        BenchmarkClusterNode {
+            name: name.to_string(),
+            role: role.to_string(),
+            status: "healthy".to_string(),
+        }
+    }
+
+    fn three_node_cluster() -> BenchmarkClusterInfo {
+        BenchmarkClusterInfo {
+            name: "vsr".to_string(),
+            nodes: vec![
+                node("node-1", "leader"),
+                node("node-2", "follower"),
+                node("node-3", "follower"),
+            ],
+        }
+    }
+
+    #[test]
+    fn 
given_single_node_report_when_formatting_params_should_omit_cluster_segment() {
+        let report = BenchmarkReportLight::default();
+        assert!(!report.format_params().contains("cluster"));
+    }
+
+    #[test]
+    fn 
given_cluster_report_when_formatting_params_should_append_one_label_segment() {
+        let single = BenchmarkReportLight::default();
+        let clustered = BenchmarkReportLight {
+            cluster: Some(three_node_cluster()),
+            ..Default::default()
+        };
+        let expected = format!(
+            "{}  •  {}",
+            single.format_params(),
+            three_node_cluster().label()
+        );
+        assert_eq!(clustered.format_params(), expected);
+    }
+}
diff --git a/core/bench/report/src/types/cluster.rs 
b/core/bench/report/src/types/cluster.rs
new file mode 100644
index 000000000..2eb7fe253
--- /dev/null
+++ b/core/bench/report/src/types/cluster.rs
@@ -0,0 +1,125 @@
+// 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 serde::{Deserialize, Serialize};
+
+/// Role string that marks a node as the consensus leader; anything else 
counts as a follower.
+const LEADER_ROLE: &str = "leader";
+
+#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
+pub struct BenchmarkClusterInfo {
+    /// Human-friendly cluster name
+    pub name: String,
+    /// Member nodes of the cluster
+    pub nodes: Vec<BenchmarkClusterNode>,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
+pub struct BenchmarkClusterNode {
+    /// Node name as reported by the roster
+    pub name: String,
+    /// Consensus role, e.g. "leader" or "follower"
+    pub role: String,
+    /// Lifecycle status, e.g. "healthy" or "starting"
+    pub status: String,
+}
+
+impl BenchmarkClusterInfo {
+    /// Human display label such as `3-node cluster (1 leader + 2 followers)`.
+    pub fn label(&self) -> String {
+        if self.nodes.is_empty() {
+            return format!("cluster '{}'", self.name);
+        }
+
+        let leaders = self
+            .nodes
+            .iter()
+            .filter(|node| node.role == LEADER_ROLE)
+            .count();
+        let followers = self.nodes.len() - leaders;
+
+        format!(
+            "{}-node cluster ({} + {})",
+            self.nodes.len(),
+            pluralize(leaders, "leader"),
+            pluralize(followers, "follower"),
+        )
+    }
+}
+
+fn pluralize(count: usize, word: &str) -> String {
+    if count == 1 {
+        format!("{count} {word}")
+    } else {
+        format!("{count} {word}s")
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn node(name: &str, role: &str) -> BenchmarkClusterNode {
+        BenchmarkClusterNode {
+            name: name.to_string(),
+            role: role.to_string(),
+            status: "healthy".to_string(),
+        }
+    }
+
+    #[test]
+    fn test_label_three_nodes_one_leader() {
+        let cluster = BenchmarkClusterInfo {
+            name: "vsr".to_string(),
+            nodes: vec![
+                node("node-1", "leader"),
+                node("node-2", "follower"),
+                node("node-3", "follower"),
+            ],
+        };
+        assert_eq!(cluster.label(), "3-node cluster (1 leader + 2 followers)");
+    }
+
+    #[test]
+    fn test_label_single_leader_node() {
+        let cluster = BenchmarkClusterInfo {
+            name: "solo".to_string(),
+            nodes: vec![node("node-1", "leader")],
+        };
+        assert_eq!(cluster.label(), "1-node cluster (1 leader + 0 followers)");
+    }
+
+    #[test]
+    fn test_label_empty_nodes_falls_back_to_name() {
+        let cluster = BenchmarkClusterInfo {
+            name: "unknown".to_string(),
+            nodes: vec![],
+        };
+        assert_eq!(cluster.label(), "cluster 'unknown'");
+    }
+
+    #[test]
+    fn test_cluster_info_round_trip() {
+        let cluster = BenchmarkClusterInfo {
+            name: "vsr".to_string(),
+            nodes: vec![node("node-1", "leader"), node("node-2", "follower")],
+        };
+        let json = serde_json::to_string(&cluster).unwrap();
+        let decoded: BenchmarkClusterInfo = 
serde_json::from_str(&json).unwrap();
+        assert_eq!(cluster, decoded);
+    }
+}
diff --git a/core/bench/report/src/types/mod.rs 
b/core/bench/report/src/types/mod.rs
index 3661476e0..508dcc08f 100644
--- a/core/bench/report/src/types/mod.rs
+++ b/core/bench/report/src/types/mod.rs
@@ -17,6 +17,7 @@
 
 pub mod actor_kind;
 pub mod benchmark_kind;
+pub mod cluster;
 pub mod group_metrics;
 pub mod group_metrics_kind;
 pub mod group_metrics_summary;
diff --git a/core/bench/report/src/types/report.rs 
b/core/bench/report/src/types/report.rs
index 01aedceec..75ae23372 100644
--- a/core/bench/report/src/types/report.rs
+++ b/core/bench/report/src/types/report.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use super::cluster::BenchmarkClusterInfo;
 use super::server_stats::BenchmarkServerStats;
 use crate::group_metrics::BenchmarkGroupMetrics;
 use crate::individual_metrics::BenchmarkIndividualMetrics;
@@ -41,6 +42,10 @@ pub struct BenchmarkReport {
     /// Benchmark parameters
     pub params: BenchmarkParams,
 
+    /// Cluster topology, present only for cluster benchmarks (None = single 
node)
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub cluster: Option<BenchmarkClusterInfo>,
+
     /// Benchmark metrics for all actors of same type (all producers, all 
consumers or all actors)
     pub group_metrics: Vec<BenchmarkGroupMetrics>,
 
@@ -58,3 +63,47 @@ impl BenchmarkReport {
         std::fs::write(report_path, report_json).expect("Failed to write 
report to file");
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::cluster::BenchmarkClusterNode;
+
+    #[test]
+    fn test_single_node_report_omits_cluster_and_deserializes_to_none() {
+        let report = BenchmarkReport::default();
+        let json = serde_json::to_string(&report).unwrap();
+        assert!(!json.contains("\"cluster\""));
+
+        let decoded: BenchmarkReport = serde_json::from_str(&json).unwrap();
+        assert_eq!(decoded.cluster, None);
+    }
+
+    #[test]
+    fn test_cluster_report_round_trip_preserves_data() {
+        let report = BenchmarkReport {
+            cluster: Some(BenchmarkClusterInfo {
+                name: "vsr".to_string(),
+                nodes: vec![
+                    BenchmarkClusterNode {
+                        name: "node-1".to_string(),
+                        role: "leader".to_string(),
+                        status: "healthy".to_string(),
+                    },
+                    BenchmarkClusterNode {
+                        name: "node-2".to_string(),
+                        role: "follower".to_string(),
+                        status: "healthy".to_string(),
+                    },
+                ],
+            }),
+            ..Default::default()
+        };
+
+        let json = serde_json::to_string(&report).unwrap();
+        assert!(json.contains("\"cluster\""));
+
+        let decoded: BenchmarkReport = serde_json::from_str(&json).unwrap();
+        assert_eq!(decoded, report);
+    }
+}
diff --git a/core/bench/src/analytics/report_builder.rs 
b/core/bench/src/analytics/report_builder.rs
index 32e67eed9..35119a32e 100644
--- a/core/bench/src/analytics/report_builder.rs
+++ b/core/bench/src/analytics/report_builder.rs
@@ -22,6 +22,7 @@ use crate::utils::get_server_stats;
 use bench_report::{
     actor_kind::ActorKind,
     benchmark_kind::BenchmarkKind,
+    cluster::{BenchmarkClusterInfo, BenchmarkClusterNode},
     hardware::BenchmarkHardware,
     individual_metrics::BenchmarkIndividualMetrics,
     params::BenchmarkParams,
@@ -29,7 +30,14 @@ use bench_report::{
     server_stats::{BenchmarkCacheMetrics, BenchmarkCacheMetricsKey, 
BenchmarkServerStats},
 };
 use chrono::{DateTime, Utc};
-use iggy::prelude::{CacheMetrics, CacheMetricsKey, IggyClient, IggyTimestamp, 
Stats};
+use iggy::prelude::{
+    CacheMetrics, CacheMetricsKey, ClusterClient, ClusterMetadata, IggyClient, 
IggyTimestamp, Stats,
+};
+use tracing::warn;
+
+/// Both the legacy server and server-ng synthesize exactly this cluster name
+/// for a non-clustered instance, so it is the single-node sentinel.
+const SINGLE_NODE_CLUSTER_NAME: &str = "single-node";
 
 pub struct BenchmarkReportBuilder;
 
@@ -60,6 +68,22 @@ impl BenchmarkReportBuilder {
             params.gitref_date = Some(timestamp.clone());
         }
 
+        // Old servers predate the cluster-metadata command; tolerate the error
+        // and treat the target as a single node rather than failing the run.
+        let cluster = match admin_client.get_cluster_metadata().await {
+            Ok(metadata) => cluster_info_from_metadata(metadata),
+            Err(error) => {
+                warn!("cluster metadata unavailable, assuming single node: 
{error}");
+                None
+            }
+        };
+
+        // params_identifier is the dashboard trend grouping key; fork cluster
+        // runs into their own series so they never mix with single-node runs.
+        params
+            .params_identifier
+            .push_str(&cluster_suffix(cluster.as_ref()));
+
         let mut group_metrics = Vec::new();
 
         individual_metrics.sort_by_key(|m| (m.summary.actor_kind, 
m.summary.actor_id));
@@ -132,12 +156,46 @@ impl BenchmarkReportBuilder {
             timestamp,
             hardware,
             params,
+            cluster,
             group_metrics,
             individual_metrics,
         }
     }
 }
 
+/// Classifies raw server metadata into cluster topology, returning `None` for 
a
+/// single node so callers can keep single-node reports untouched.
+fn cluster_info_from_metadata(metadata: ClusterMetadata) -> 
Option<BenchmarkClusterInfo> {
+    let is_real_cluster = metadata.nodes.len() > 1 || metadata.name != 
SINGLE_NODE_CLUSTER_NAME;
+    if !is_real_cluster {
+        return None;
+    }
+
+    let nodes = metadata
+        .nodes
+        .into_iter()
+        .map(|node| BenchmarkClusterNode {
+            name: node.name,
+            role: node.role.to_string(),
+            status: node.status.to_string(),
+        })
+        .collect();
+
+    Some(BenchmarkClusterInfo {
+        name: metadata.name,
+        nodes,
+    })
+}
+
+/// Trend/directory suffix that forks a cluster run away from the single-node
+/// run sharing the same benchmark params. Empty for single node so existing
+/// identifiers and directory names stay byte-identical.
+pub fn cluster_suffix(cluster: Option<&BenchmarkClusterInfo>) -> String {
+    cluster.map_or_else(String::new, |cluster| {
+        format!("_cluster{}", cluster.nodes.len())
+    })
+}
+
 /// This function is a workaround.
 /// See `server_stats.rs` in `bench_report` crate for more details.
 fn stats_to_benchmark_server_stats(stats: Stats) -> BenchmarkServerStats {
@@ -193,3 +251,90 @@ fn cache_metrics_to_benchmark_cache_metrics(
         })
         .collect()
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use iggy::prelude::{ClusterNode, ClusterNodeRole, ClusterNodeStatus, 
TransportEndpoints};
+
+    fn metadata_node(name: &str, role: ClusterNodeRole) -> ClusterNode {
+        ClusterNode {
+            name: name.to_string(),
+            ip: "127.0.0.1".to_string(),
+            endpoints: TransportEndpoints::new(0, 0, 0, 0),
+            role,
+            status: ClusterNodeStatus::Healthy,
+        }
+    }
+
+    fn benchmark_cluster(node_count: usize) -> BenchmarkClusterInfo {
+        BenchmarkClusterInfo {
+            name: "vsr".to_string(),
+            nodes: (0..node_count)
+                .map(|idx| BenchmarkClusterNode {
+                    name: format!("node-{idx}"),
+                    role: "follower".to_string(),
+                    status: "healthy".to_string(),
+                })
+                .collect(),
+        }
+    }
+
+    #[test]
+    fn given_single_node_metadata_when_classified_should_return_none() {
+        let metadata = ClusterMetadata {
+            name: SINGLE_NODE_CLUSTER_NAME.to_string(),
+            nodes: vec![metadata_node("iggy-node", ClusterNodeRole::Leader)],
+        };
+
+        assert!(cluster_info_from_metadata(metadata).is_none());
+    }
+
+    #[test]
+    fn 
given_multi_node_metadata_when_classified_should_map_lowercase_roles_and_keep_name()
 {
+        let metadata = ClusterMetadata {
+            name: "vsr".to_string(),
+            nodes: vec![
+                metadata_node("node-1", ClusterNodeRole::Leader),
+                metadata_node("node-2", ClusterNodeRole::Follower),
+                metadata_node("node-3", ClusterNodeRole::Follower),
+            ],
+        };
+
+        let info = cluster_info_from_metadata(metadata).expect("real cluster");
+        assert_eq!(info.name, "vsr");
+        assert_eq!(
+            info.nodes
+                .iter()
+                .map(|node| node.role.as_str())
+                .collect::<Vec<_>>(),
+            vec!["leader", "follower", "follower"]
+        );
+        assert!(info.nodes.iter().all(|node| node.status == "healthy"));
+    }
+
+    #[test]
+    fn given_single_node_with_custom_name_when_classified_should_return_some() 
{
+        let metadata = ClusterMetadata {
+            name: "vsr".to_string(),
+            nodes: vec![metadata_node("node-1", ClusterNodeRole::Leader)],
+        };
+
+        let info = cluster_info_from_metadata(metadata).expect("named roster 
counts as cluster");
+        assert_eq!(info.nodes.len(), 1);
+    }
+
+    #[test]
+    fn given_cluster_when_suffixing_identifier_should_append_node_count() {
+        let mut identifier = "pinned_producer_tcp".to_string();
+        identifier.push_str(&cluster_suffix(Some(&benchmark_cluster(3))));
+        assert_eq!(identifier, "pinned_producer_tcp_cluster3");
+    }
+
+    #[test]
+    fn given_no_cluster_when_suffixing_identifier_should_leave_it_unchanged() {
+        let mut identifier = "pinned_producer_tcp".to_string();
+        identifier.push_str(&cluster_suffix(None));
+        assert_eq!(identifier, "pinned_producer_tcp");
+    }
+}
diff --git a/core/bench/src/runner.rs b/core/bench/src/runner.rs
index 04afd2c85..49f9e8bfe 100644
--- a/core/bench/src/runner.rs
+++ b/core/bench/src/runner.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::analytics::report_builder::BenchmarkReportBuilder;
+use crate::analytics::report_builder::{BenchmarkReportBuilder, cluster_suffix};
 use crate::args::common::IggyBenchArgs;
 use crate::benchmarks::benchmark::Benchmarkable;
 use crate::plot::{ChartType, plot_chart};
@@ -93,6 +93,9 @@ impl BenchmarkRunner {
             // Generate the full output path using the directory name generator
             let mut dir_name = benchmark.args().generate_dir_name();
             append_cpu_name_lowercase(&mut dir_name);
+            // Cluster runs share params (and thus the dir name) with 
single-node
+            // runs; suffix keeps them from overwriting each other's results.
+            dir_name.push_str(&cluster_suffix(report.cluster.as_ref()));
             let full_output_path = Path::new(&output_dir)
                 .join(dir_name.clone())
                 .to_string_lossy()
diff --git a/core/server-ng/Cargo.toml b/core/server-ng/Cargo.toml
index 558504dbc..b42c2a3cb 100644
--- a/core/server-ng/Cargo.toml
+++ b/core/server-ng/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "server-ng"
-version = "0.8.0"
+version = "0.9.0-edge.1"
 edition = "2024"
 license = "Apache-2.0"
 publish = false


Reply via email to