diff --git a/backend/common/src/node_message.rs b/backend/common/src/node_message.rs index 1448ffd7..45d40d17 100644 --- a/backend/common/src/node_message.rs +++ b/backend/common/src/node_message.rs @@ -60,6 +60,7 @@ pub enum Payload { BlockImport(Block), NotifyFinalized(Finalized), AfgAuthoritySet(AfgAuthoritySet), + HwBench(NodeHwBench), } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -93,6 +94,14 @@ pub struct AfgAuthoritySet { pub authority_set_id: Box, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct NodeHwBench { + pub cpu_hashrate_score: u64, + pub memory_memcpy_score: u64, + pub disk_sequential_write_score: Option, + pub disk_random_write_score: Option, +} + impl Payload { pub fn best_block(&self) -> Option<&Block> { match self { @@ -145,9 +154,13 @@ mod tests { name: "foo".into(), implementation: "foo".into(), version: "foo".into(), + target_arch: Some("x86_64".into()), + target_os: Some("linux".into()), + target_env: Some("env".into()), validator: None, network_id: ArrayString::new(), startup_time: None, + sysinfo: None, }, }), }); diff --git a/backend/common/src/node_types.rs b/backend/common/src/node_types.rs index 7f6d4448..74816d1d 100644 --- a/backend/common/src/node_types.rs +++ b/backend/common/src/node_types.rs @@ -38,6 +38,40 @@ pub struct NodeDetails { pub validator: Option>, pub network_id: NetworkId, pub startup_time: Option>, + pub target_os: Option>, + pub target_arch: Option>, + pub target_env: Option>, + pub sysinfo: Option, +} + +/// Hardware and software information for the node. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct NodeSysInfo { + /// The exact CPU model. + pub cpu: Option>, + /// The total amount of memory, in bytes. + pub memory: Option, + /// The number of physical CPU cores. + pub core_count: Option, + /// The Linux kernel version. + pub linux_kernel: Option>, + /// The exact Linux distribution used. + pub linux_distro: Option>, + /// Whether the node's running under a virtual machine. + pub is_virtual_machine: Option, +} + +/// Hardware benchmark results for the node. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct NodeHwBench { + /// The CPU speed, as measured in how many MB/s it can hash using the BLAKE2b-256 hash. + pub cpu_hashrate_score: u64, + /// Memory bandwidth in MB/s, calculated by measuring the throughput of `memcpy`. + pub memory_memcpy_score: u64, + /// Sequential disk write speed in MB/s. + pub disk_sequential_write_score: Option, + /// Random disk write speed in MB/s. + pub disk_random_write_score: Option, } /// A couple of node statistics. diff --git a/backend/telemetry_core/src/aggregator/inner_loop.rs b/backend/telemetry_core/src/aggregator/inner_loop.rs index ef6973e3..d092150f 100644 --- a/backend/telemetry_core/src/aggregator/inner_loop.rs +++ b/backend/telemetry_core/src/aggregator/inner_loop.rs @@ -514,6 +514,7 @@ impl InnerLoop { new_chain.finalized_block().height, new_chain.finalized_block().hash, )); + feed_serializer.push(feed_message::ChainStatsUpdate(new_chain.stats())); if let Some(bytes) = feed_serializer.into_finalized() { let _ = feed_channel.send(ToFeedWebsocket::Bytes(bytes)); } diff --git a/backend/telemetry_core/src/feed_message.rs b/backend/telemetry_core/src/feed_message.rs index 7b63274b..fdf417f0 100644 --- a/backend/telemetry_core/src/feed_message.rs +++ b/backend/telemetry_core/src/feed_message.rs @@ -122,6 +122,7 @@ actions! { // We maintain existing IDs for backward compatibility. 20: StaleNode, 21: NodeIOUpdate<'_>, + 22: ChainStatsUpdate<'_>, } #[derive(Serialize)] @@ -202,3 +203,30 @@ impl FeedMessageWrite for AddedNode<'_> { )); } } + +#[derive(Serialize)] +pub struct ChainStatsUpdate<'a>(pub &'a ChainStats); + +#[derive(Serialize, PartialEq, Eq, Default)] +pub struct Ranking { + pub list: Vec<(K, u64)>, + pub other: u64, + pub unknown: u64, +} + +#[derive(Serialize, PartialEq, Eq, Default)] +pub struct ChainStats { + pub version: Ranking, + pub target_os: Ranking, + pub target_arch: Ranking, + pub cpu: Ranking, + pub memory: Ranking<(u32, Option)>, + pub core_count: Ranking, + pub linux_kernel: Ranking, + pub linux_distro: Ranking, + pub is_virtual_machine: Ranking, + pub cpu_hashrate_score: Ranking<(u32, Option)>, + pub memory_memcpy_score: Ranking<(u32, Option)>, + pub disk_sequential_write_score: Ranking<(u32, Option)>, + pub disk_random_write_score: Ranking<(u32, Option)>, +} diff --git a/backend/telemetry_core/src/state/chain.rs b/backend/telemetry_core/src/state/chain.rs index ce11f227..1e1ca374 100644 --- a/backend/telemetry_core/src/state/chain.rs +++ b/backend/telemetry_core/src/state/chain.rs @@ -21,10 +21,13 @@ use common::{id_type, time, DenseMap, MostSeen, NumStats}; use once_cell::sync::Lazy; use std::collections::HashSet; use std::str::FromStr; +use std::time::{Duration, Instant}; -use crate::feed_message::{self, FeedMessageSerializer}; +use crate::feed_message::{self, ChainStats, FeedMessageSerializer}; use crate::find_location; +use super::chain_stats::ChainStatsCollator; +use super::counter::CounterValue; use super::node::Node; id_type! { @@ -35,6 +38,7 @@ id_type! { pub type Label = Box; const STALE_TIMEOUT: u64 = 2 * 60 * 1000; // 2 minutes +const STATS_UPDATE_INTERVAL: Duration = Duration::from_secs(5); pub struct Chain { /// Labels that nodes use for this chain. We keep track of @@ -56,6 +60,12 @@ pub struct Chain { genesis_hash: BlockHash, /// Maximum number of nodes allowed to connect from this chain max_nodes: usize, + /// Collator for the stats. + stats_collator: ChainStatsCollator, + /// Stats for this chain. + stats: ChainStats, + /// Timestamp of when the stats were last regenerated. + stats_last_regenerated: Instant, } pub enum AddNodeResult { @@ -105,6 +115,9 @@ impl Chain { timestamp: None, genesis_hash, max_nodes, + stats_collator: Default::default(), + stats: Default::default(), + stats_last_regenerated: Instant::now(), } } @@ -119,7 +132,11 @@ impl Chain { return AddNodeResult::Overquota; } - let node_chain_label = &node.details().chain; + let details = node.details(); + self.stats_collator + .add_or_remove_node(details, None, CounterValue::Increment); + + let node_chain_label = &details.chain; let label_result = self.labels.insert(node_chain_label); let node_id = self.nodes.add(node); @@ -140,6 +157,10 @@ impl Chain { } }; + let details = node.details(); + self.stats_collator + .add_or_remove_node(details, node.hwbench(), CounterValue::Decrement); + let node_chain_label = &node.details().chain; let label_result = self.labels.remove(node_chain_label); @@ -181,6 +202,19 @@ impl Chain { } return; } + Payload::HwBench(ref hwbench) => { + let new_hwbench = common::node_types::NodeHwBench { + cpu_hashrate_score: hwbench.cpu_hashrate_score, + memory_memcpy_score: hwbench.memory_memcpy_score, + disk_sequential_write_score: hwbench.disk_sequential_write_score, + disk_random_write_score: hwbench.disk_random_write_score, + }; + let old_hwbench = node.update_hwbench(new_hwbench); + self.stats_collator + .update_hwbench(old_hwbench.as_ref(), CounterValue::Decrement); + self.stats_collator + .update_hwbench(node.hwbench(), CounterValue::Increment); + } _ => {} } @@ -210,6 +244,7 @@ impl Chain { let nodes_len = self.nodes.len(); self.update_stale_nodes(now, feed); + self.regenerate_stats_if_necessary(feed); let node = match self.nodes.get_mut(nid) { Some(node) => node, @@ -300,6 +335,21 @@ impl Chain { } } + fn regenerate_stats_if_necessary(&mut self, feed: &mut FeedMessageSerializer) { + let now = Instant::now(); + let elapsed = now - self.stats_last_regenerated; + if elapsed < STATS_UPDATE_INTERVAL { + return; + } + + self.stats_last_regenerated = now; + let new_stats = self.stats_collator.generate(); + if new_stats != self.stats { + self.stats = new_stats; + feed.push(feed_message::ChainStatsUpdate(&self.stats)); + } + } + pub fn update_node_location( &mut self, node_id: ChainNodeId, @@ -340,4 +390,7 @@ impl Chain { pub fn genesis_hash(&self) -> BlockHash { self.genesis_hash } + pub fn stats(&self) -> &ChainStats { + &self.stats + } } diff --git a/backend/telemetry_core/src/state/chain_stats.rs b/backend/telemetry_core/src/state/chain_stats.rs new file mode 100644 index 00000000..95bca789 --- /dev/null +++ b/backend/telemetry_core/src/state/chain_stats.rs @@ -0,0 +1,225 @@ +// Source code for the Substrate Telemetry Server. +// Copyright (C) 2022 Parity Technologies (UK) Ltd. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use super::counter::{Counter, CounterValue}; +use crate::feed_message::ChainStats; + +// These are the benchmark scores generated on our reference hardware. +const REFERENCE_CPU_SCORE: u64 = 1028; +const REFERENCE_MEMORY_SCORE: u64 = 14899; +const REFERENCE_DISK_SEQUENTIAL_WRITE_SCORE: u64 = 485; +const REFERENCE_DISK_RANDOM_WRITE_SCORE: u64 = 222; + +macro_rules! buckets { + (@try $value:expr, $bucket_min:expr, $bucket_max:expr,) => { + if $value < $bucket_max { + return ($bucket_min, Some($bucket_max)); + } + }; + + ($value:expr, $bucket_min:expr, $bucket_max:expr, $($remaining:expr,)*) => { + buckets! { @try $value, $bucket_min, $bucket_max, } + buckets! { $value, $bucket_max, $($remaining,)* } + }; + + ($value:expr, $bucket_last:expr,) => { + ($bucket_last, None) + } +} + +/// Translates a given raw benchmark score into a relative measure +/// of how the score compares to the reference score. +/// +/// The value returned is the range (in percent) within which the given score +/// falls into. For example, a value of `(90, Some(110))` means that the score +/// is between 90% and 110% of the reference score, with the lower bound being +/// inclusive and the upper bound being exclusive. +fn bucket_score(score: u64, reference_score: u64) -> (u32, Option) { + let relative_score = ((score as f64 / reference_score as f64) * 100.0) as u32; + + buckets! { + relative_score, + 0, + 10, + 30, + 50, + 70, + 90, + 110, + 130, + 150, + 200, + 300, + 400, + 500, + } +} + +#[test] +fn test_bucket_score() { + assert_eq!(bucket_score(0, 100), (0, Some(10))); + assert_eq!(bucket_score(9, 100), (0, Some(10))); + assert_eq!(bucket_score(10, 100), (10, Some(30))); + assert_eq!(bucket_score(29, 100), (10, Some(30))); + assert_eq!(bucket_score(30, 100), (30, Some(50))); + assert_eq!(bucket_score(100, 100), (90, Some(110))); + assert_eq!(bucket_score(500, 100), (500, None)); +} + +fn bucket_memory(memory: u64) -> (u32, Option) { + let memory = memory / (1024 * 1024) / 1000; + + buckets! { + memory, + 1, + 2, + 4, + 6, + 8, + 10, + 16, + 24, + 32, + 48, + 56, + 64, + } +} + +#[derive(Default)] +pub struct ChainStatsCollator { + version: Counter, + target_os: Counter, + target_arch: Counter, + cpu: Counter, + memory: Counter<(u32, Option)>, + core_count: Counter, + linux_kernel: Counter, + linux_distro: Counter, + is_virtual_machine: Counter, + cpu_hashrate_score: Counter<(u32, Option)>, + memory_memcpy_score: Counter<(u32, Option)>, + disk_sequential_write_score: Counter<(u32, Option)>, + disk_random_write_score: Counter<(u32, Option)>, +} + +impl ChainStatsCollator { + pub fn add_or_remove_node( + &mut self, + details: &common::node_types::NodeDetails, + hwbench: Option<&common::node_types::NodeHwBench>, + op: CounterValue, + ) { + self.version.modify(Some(&*details.version), op); + + self.target_os + .modify(details.target_os.as_ref().map(|value| &**value), op); + + self.target_arch + .modify(details.target_arch.as_ref().map(|value| &**value), op); + + let sysinfo = details.sysinfo.as_ref(); + self.cpu.modify( + sysinfo + .and_then(|sysinfo| sysinfo.cpu.as_ref()) + .map(|value| &**value), + op, + ); + + let memory = sysinfo.and_then(|sysinfo| sysinfo.memory.map(bucket_memory)); + self.memory.modify(memory.as_ref(), op); + + self.core_count + .modify(sysinfo.and_then(|sysinfo| sysinfo.core_count.as_ref()), op); + + self.linux_kernel.modify( + sysinfo + .and_then(|sysinfo| sysinfo.linux_kernel.as_ref()) + .map(|value| &**value), + op, + ); + + self.linux_distro.modify( + sysinfo + .and_then(|sysinfo| sysinfo.linux_distro.as_ref()) + .map(|value| &**value), + op, + ); + + self.is_virtual_machine.modify( + sysinfo.and_then(|sysinfo| sysinfo.is_virtual_machine.as_ref()), + op, + ); + + self.update_hwbench(hwbench, op); + } + + pub fn update_hwbench( + &mut self, + hwbench: Option<&common::node_types::NodeHwBench>, + op: CounterValue, + ) { + self.cpu_hashrate_score.modify( + hwbench + .map(|hwbench| bucket_score(hwbench.cpu_hashrate_score, REFERENCE_CPU_SCORE)) + .as_ref(), + op, + ); + + self.memory_memcpy_score.modify( + hwbench + .map(|hwbench| bucket_score(hwbench.memory_memcpy_score, REFERENCE_MEMORY_SCORE)) + .as_ref(), + op, + ); + + self.disk_sequential_write_score.modify( + hwbench + .and_then(|hwbench| hwbench.disk_sequential_write_score) + .map(|score| bucket_score(score, REFERENCE_DISK_SEQUENTIAL_WRITE_SCORE)) + .as_ref(), + op, + ); + + self.disk_random_write_score.modify( + hwbench + .and_then(|hwbench| hwbench.disk_random_write_score) + .map(|score| bucket_score(score, REFERENCE_DISK_RANDOM_WRITE_SCORE)) + .as_ref(), + op, + ); + } + + pub fn generate(&self) -> ChainStats { + ChainStats { + version: self.version.generate_ranking_top(10), + target_os: self.target_os.generate_ranking_top(10), + target_arch: self.target_arch.generate_ranking_top(10), + cpu: self.cpu.generate_ranking_top(10), + memory: self.memory.generate_ranking_ordered(), + core_count: self.core_count.generate_ranking_top(10), + linux_kernel: self.linux_kernel.generate_ranking_top(10), + linux_distro: self.linux_distro.generate_ranking_top(10), + is_virtual_machine: self.is_virtual_machine.generate_ranking_ordered(), + cpu_hashrate_score: self.cpu_hashrate_score.generate_ranking_top(10), + memory_memcpy_score: self.memory_memcpy_score.generate_ranking_ordered(), + disk_sequential_write_score: self + .disk_sequential_write_score + .generate_ranking_ordered(), + disk_random_write_score: self.disk_random_write_score.generate_ranking_ordered(), + } + } +} diff --git a/backend/telemetry_core/src/state/counter.rs b/backend/telemetry_core/src/state/counter.rs new file mode 100644 index 00000000..d3e12679 --- /dev/null +++ b/backend/telemetry_core/src/state/counter.rs @@ -0,0 +1,119 @@ +// Source code for the Substrate Telemetry Server. +// Copyright (C) 2022 Parity Technologies (UK) Ltd. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use crate::feed_message::Ranking; +use std::collections::HashMap; + +/// A data structure which counts how many occurences of a given key we've seen. +#[derive(Default)] +pub struct Counter { + /// A map containing the number of occurences of a given key. + /// + /// If there are none then the entry is removed. + map: HashMap, + + /// The number of occurences where the key is `None`. + empty: u64, +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum CounterValue { + Increment, + Decrement, +} + +impl Counter +where + K: Sized + std::hash::Hash + Eq, +{ + /// Either adds or removes a single occurence of a given `key`. + pub fn modify<'a, Q>(&mut self, key: Option<&'a Q>, op: CounterValue) + where + Q: ?Sized + std::hash::Hash + Eq, + K: std::borrow::Borrow, + Q: std::borrow::ToOwned, + { + if let Some(key) = key { + if let Some(entry) = self.map.get_mut(key) { + match op { + CounterValue::Increment => { + *entry += 1; + } + CounterValue::Decrement => { + *entry -= 1; + if *entry == 0 { + // Don't keep entries for which there are no hits. + self.map.remove(key); + } + } + } + } else { + assert_eq!(op, CounterValue::Increment); + self.map.insert(key.to_owned(), 1); + } + } else { + match op { + CounterValue::Increment => { + self.empty += 1; + } + CounterValue::Decrement => { + self.empty -= 1; + } + } + } + } + + /// Generates a top-N table of the most common keys. + pub fn generate_ranking_top(&self, max_count: usize) -> Ranking + where + K: Clone, + { + let mut all: Vec<(&K, u64)> = self.map.iter().map(|(key, count)| (key, *count)).collect(); + all.sort_unstable_by_key(|&(_, count)| !count); + + let list = all + .iter() + .take(max_count) + .map(|&(key, count)| (key.clone(), count)) + .collect(); + + let other = all + .iter() + .skip(max_count) + .fold(0, |sum, (_, count)| sum + *count); + + Ranking { + list, + other, + unknown: self.empty, + } + } + + /// Generates a sorted table of all of the keys. + pub fn generate_ranking_ordered(&self) -> Ranking + where + K: Copy + Clone + Ord, + { + let mut list: Vec<(K, u64)> = self.map.iter().map(|(key, count)| (*key, *count)).collect(); + list.sort_unstable_by_key(|&(key, count)| (key, !count)); + + Ranking { + list, + other: 0, + unknown: self.empty, + } + } +} diff --git a/backend/telemetry_core/src/state/mod.rs b/backend/telemetry_core/src/state/mod.rs index 2576f5c3..01841228 100644 --- a/backend/telemetry_core/src/state/mod.rs +++ b/backend/telemetry_core/src/state/mod.rs @@ -15,6 +15,8 @@ // along with this program. If not, see . mod chain; +mod chain_stats; +mod counter; mod node; mod state; diff --git a/backend/telemetry_core/src/state/node.rs b/backend/telemetry_core/src/state/node.rs index dbaa58b8..c9365fdd 100644 --- a/backend/telemetry_core/src/state/node.rs +++ b/backend/telemetry_core/src/state/node.rs @@ -17,7 +17,8 @@ use crate::find_location; use common::node_message::SystemInterval; use common::node_types::{ - Block, BlockDetails, NodeDetails, NodeHardware, NodeIO, NodeLocation, NodeStats, Timestamp, + Block, BlockDetails, NodeDetails, NodeHardware, NodeHwBench, NodeIO, NodeLocation, NodeStats, + Timestamp, }; use common::time; @@ -47,6 +48,8 @@ pub struct Node { stale: bool, /// Unix timestamp for when node started up (falls back to connection time) startup_time: Option, + /// Hardware benchmark results for the node + hwbench: Option, } impl Node { @@ -67,6 +70,7 @@ impl Node { location: None, stale: false, startup_time, + hwbench: None, } } @@ -110,6 +114,14 @@ impl Node { &self.best } + pub fn hwbench(&self) -> Option<&NodeHwBench> { + self.hwbench.as_ref() + } + + pub fn update_hwbench(&mut self, hwbench: NodeHwBench) -> Option { + self.hwbench.replace(hwbench) + } + pub fn update_block(&mut self, block: Block) -> bool { if block.height > self.best.block.height { self.stale = false; diff --git a/backend/telemetry_core/src/state/state.rs b/backend/telemetry_core/src/state/state.rs index 63377391..671c73fd 100644 --- a/backend/telemetry_core/src/state/state.rs +++ b/backend/telemetry_core/src/state/state.rs @@ -15,7 +15,7 @@ // along with this program. If not, see . use super::node::Node; -use crate::feed_message::FeedMessageSerializer; +use crate::feed_message::{ChainStats, FeedMessageSerializer}; use crate::find_location; use common::node_message::Payload; use common::node_types::{Block, BlockHash, NodeDetails, Timestamp}; @@ -277,6 +277,9 @@ impl<'a> StateChain<'a> { pub fn nodes_slice(&self) -> &[Option] { self.chain.nodes_slice() } + pub fn stats(&self) -> &ChainStats { + self.chain.stats() + } } #[cfg(test)] @@ -289,10 +292,14 @@ mod test { chain: chain.into(), name: name.into(), implementation: "Bar".into(), + target_arch: Some("x86_64".into()), + target_os: Some("linux".into()), + target_env: Some("env".into()), version: "0.1".into(), validator: None, network_id: NetworkId::new(), startup_time: None, + sysinfo: None, } } diff --git a/backend/telemetry_shard/src/json_message/node_message.rs b/backend/telemetry_shard/src/json_message/node_message.rs index 75468f5a..2b45cbeb 100644 --- a/backend/telemetry_shard/src/json_message/node_message.rs +++ b/backend/telemetry_shard/src/json_message/node_message.rs @@ -76,6 +76,8 @@ pub enum Payload { NotifyFinalized(Finalized), #[serde(rename = "afg.authority_set")] AfgAuthoritySet(AfgAuthoritySet), + #[serde(rename = "sysinfo.hwbench")] + HwBench(NodeHwBench), } impl From for internal::Payload { @@ -86,6 +88,7 @@ impl From for internal::Payload { Payload::BlockImport(m) => internal::Payload::BlockImport(m.into()), Payload::NotifyFinalized(m) => internal::Payload::NotifyFinalized(m.into()), Payload::AfgAuthoritySet(m) => internal::Payload::AfgAuthoritySet(m.into()), + Payload::HwBench(m) => internal::Payload::HwBench(m.into()), } } } @@ -183,6 +186,59 @@ impl From for node_types::Block { } } +#[derive(Deserialize, Debug, Clone)] +pub struct NodeSysInfo { + pub cpu: Option>, + pub memory: Option, + pub core_count: Option, + pub linux_kernel: Option>, + pub linux_distro: Option>, + pub is_virtual_machine: Option, +} + +impl From for node_types::NodeSysInfo { + fn from(sysinfo: NodeSysInfo) -> Self { + node_types::NodeSysInfo { + cpu: sysinfo.cpu, + memory: sysinfo.memory, + core_count: sysinfo.core_count, + linux_kernel: sysinfo.linux_kernel, + linux_distro: sysinfo.linux_distro, + is_virtual_machine: sysinfo.is_virtual_machine, + } + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct NodeHwBench { + pub cpu_hashrate_score: u64, + pub memory_memcpy_score: u64, + pub disk_sequential_write_score: Option, + pub disk_random_write_score: Option, +} + +impl From for node_types::NodeHwBench { + fn from(hwbench: NodeHwBench) -> Self { + node_types::NodeHwBench { + cpu_hashrate_score: hwbench.cpu_hashrate_score, + memory_memcpy_score: hwbench.memory_memcpy_score, + disk_sequential_write_score: hwbench.disk_sequential_write_score, + disk_random_write_score: hwbench.disk_random_write_score, + } + } +} + +impl From for internal::NodeHwBench { + fn from(msg: NodeHwBench) -> Self { + internal::NodeHwBench { + cpu_hashrate_score: msg.cpu_hashrate_score, + memory_memcpy_score: msg.memory_memcpy_score, + disk_sequential_write_score: msg.disk_sequential_write_score, + disk_random_write_score: msg.disk_random_write_score, + } + } +} + #[derive(Deserialize, Debug, Clone)] pub struct NodeDetails { pub chain: Box, @@ -192,10 +248,30 @@ pub struct NodeDetails { pub validator: Option>, pub network_id: node_types::NetworkId, pub startup_time: Option>, + pub target_os: Option>, + pub target_arch: Option>, + pub target_env: Option>, + pub sysinfo: Option, } impl From for node_types::NodeDetails { - fn from(details: NodeDetails) -> Self { + fn from(mut details: NodeDetails) -> Self { + // Migrate old-style `version` to the split metrics. + // TODO: Remove this once everyone updates their nodes. + if details.target_os.is_none() + && details.target_arch.is_none() + && details.target_env.is_none() + { + if let Some((version, target_arch, target_os, target_env)) = + split_old_style_version(&details.version) + { + details.target_arch = Some(target_arch.into()); + details.target_os = Some(target_os.into()); + details.target_env = Some(target_env.into()); + details.version = version.into(); + } + } + node_types::NodeDetails { chain: details.chain, name: details.name, @@ -204,6 +280,10 @@ impl From for node_types::NodeDetails { validator: details.validator, network_id: details.network_id, startup_time: details.startup_time, + target_os: details.target_os, + target_arch: details.target_arch, + target_env: details.target_env, + sysinfo: details.sysinfo.map(|sysinfo| sysinfo.into()), } } } @@ -211,6 +291,52 @@ impl From for node_types::NodeDetails { type NodeMessageId = u64; type BlockNumber = u64; +fn is_version_or_hash(name: &str) -> bool { + name.bytes().all(|byte| { + byte.is_ascii_digit() + || byte == b'.' + || byte == b'a' + || byte == b'b' + || byte == b'c' + || byte == b'd' + || byte == b'e' + || byte == b'f' + }) +} + +/// Split an old style version string into its version + target_arch + target_os + target_arch parts. +fn split_old_style_version(version_and_target: &str) -> Option<(&str, &str, &str, &str)> { + // Old style versions are composed of the following parts: + // $version-$commit_hash-$arch-$os-$env + // where $commit_hash and $env are optional. + // + // For example these are all valid: + // 0.9.17-75dd6c7d0-x86_64-linux-gnu + // 0.9.17-75dd6c7d0-x86_64-linux + // 0.9.17-x86_64-linux-gnu + // 0.9.17-x86_64-linux + // 2.0.0-alpha.5-da487d19d-x86_64-linux + + let mut iter = version_and_target.rsplit('-').take(3).skip(2); + + // This will one of these: $arch, $commit_hash, $version + let item = iter.next()?; + + let target_offset = if is_version_or_hash(item) { + item.as_ptr() as usize + item.len() + 1 + } else { + item.as_ptr() as usize + } - version_and_target.as_ptr() as usize; + + let version = version_and_target.get(0..target_offset - 1)?; + let mut target = version_and_target.get(target_offset..)?.split('-'); + let target_arch = target.next()?; + let target_os = target.next()?; + let target_env = target.next().unwrap_or(""); + + Some((version, target_arch, target_os, target_env)) +} + #[cfg(test)] mod tests { use super::*; @@ -279,4 +405,46 @@ mod tests { "message did not match the expected output", ); } + + #[test] + fn split_old_style_version_works() { + let (version, target_arch, target_os, target_env) = + split_old_style_version("0.9.17-75dd6c7d0-x86_64-linux-gnu").unwrap(); + assert_eq!(version, "0.9.17-75dd6c7d0"); + assert_eq!(target_arch, "x86_64"); + assert_eq!(target_os, "linux"); + assert_eq!(target_env, "gnu"); + + let (version, target_arch, target_os, target_env) = + split_old_style_version("0.9.17-75dd6c7d0-x86_64-linux").unwrap(); + assert_eq!(version, "0.9.17-75dd6c7d0"); + assert_eq!(target_arch, "x86_64"); + assert_eq!(target_os, "linux"); + assert_eq!(target_env, ""); + + let (version, target_arch, target_os, target_env) = + split_old_style_version("0.9.17-x86_64-linux-gnu").unwrap(); + assert_eq!(version, "0.9.17"); + assert_eq!(target_arch, "x86_64"); + assert_eq!(target_os, "linux"); + assert_eq!(target_env, "gnu"); + + let (version, target_arch, target_os, target_env) = + split_old_style_version("0.9.17-x86_64-linux").unwrap(); + assert_eq!(version, "0.9.17"); + assert_eq!(target_arch, "x86_64"); + assert_eq!(target_os, "linux"); + assert_eq!(target_env, ""); + + let (version, target_arch, target_os, target_env) = + split_old_style_version("2.0.0-alpha.5-da487d19d-x86_64-linux").unwrap(); + assert_eq!(version, "2.0.0-alpha.5-da487d19d"); + assert_eq!(target_arch, "x86_64"); + assert_eq!(target_os, "linux"); + assert_eq!(target_env, ""); + + assert_eq!(split_old_style_version(""), None); + assert_eq!(split_old_style_version("a"), None); + assert_eq!(split_old_style_version("a-b"), None); + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a275b9d9..d0b91758 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -112,6 +112,7 @@ export default class App extends React.Component<{}, {}> { sortBy: this.sortBy.get(), selectedColumns: this.selectedColumns(this.settings.raw()), tab, + chainStats: null, }); this.appState = this.appUpdate({}); diff --git a/frontend/src/Connection.ts b/frontend/src/Connection.ts index 85abba94..2ee7b2c5 100644 --- a/frontend/src/Connection.ts +++ b/frontend/src/Connection.ts @@ -362,6 +362,11 @@ export class Connection { break; } + case ACTIONS.ChainStatsUpdate: { + this.appUpdate({ chainStats: message.payload }); + break; + } + default: { break; } diff --git a/frontend/src/common/feed.ts b/frontend/src/common/feed.ts index 044c6458..ac6033b7 100644 --- a/frontend/src/common/feed.ts +++ b/frontend/src/common/feed.ts @@ -37,6 +37,7 @@ import { ChainLabel, GenesisHash, AuthoritySetInfo, + ChainStats, } from './types'; export const ACTIONS = { @@ -62,6 +63,7 @@ export const ACTIONS = { AfgAuthoritySet: 0x13 as 0x13, StaleNode: 0x14 as 0x14, NodeIO: 0x15 as 0x15, + ChainStatsUpdate: 0x16 as 0x16, }; export type Action = typeof ACTIONS[keyof typeof ACTIONS]; @@ -190,6 +192,11 @@ export namespace Variants { action: typeof ACTIONS.StaleNode; payload: NodeId; } + + export interface ChainStatsUpdate extends MessageBase { + action: typeof ACTIONS.ChainStatsUpdate; + payload: ChainStats; + } } export type Message = @@ -214,7 +221,8 @@ export type Message = | Variants.AfgAuthoritySet | Variants.StaleNodeMessage | Variants.PongMessage - | Variants.NodeIOMessage; + | Variants.NodeIOMessage + | Variants.ChainStatsUpdate; /** * Data type to be sent to the feed. Passing through strings means we can only serialize once, diff --git a/frontend/src/common/types.ts b/frontend/src/common/types.ts index 3f984ad3..1edfb0b3 100644 --- a/frontend/src/common/types.ts +++ b/frontend/src/common/types.ts @@ -87,3 +87,27 @@ export declare type ImplicitPrecommit = Opaque; export declare type ImplicitPrevote = Opaque; export declare type ImplicitFinalized = Opaque; export declare type ImplicitPointer = Opaque; + +export type Ranking = { + list: Array<[T, number]>; + other: number; + unknown: number; +}; + +export type Range = [number, number | null]; + +export type ChainStats = { + version: Maybe>; + target_os: Maybe>; + target_arch: Maybe>; + cpu: Maybe>; + core_count: Maybe>; + memory: Maybe>; + is_virtual_machine: Maybe>; + linux_distro: Maybe>; + linux_kernel: Maybe>; + cpu_hashrate_score: Maybe>; + memory_memcpy_score: Maybe>; + disk_sequential_write_score: Maybe>; + disk_random_write_score: Maybe>; +}; diff --git a/frontend/src/components/Chain/Chain.tsx b/frontend/src/components/Chain/Chain.tsx index cd2d5128..c48a0128 100644 --- a/frontend/src/components/Chain/Chain.tsx +++ b/frontend/src/components/Chain/Chain.tsx @@ -20,13 +20,13 @@ import { Types, Maybe } from '../../common'; import { State as AppState, Update as AppUpdate } from '../../state'; import { getHashData } from '../../utils'; import { Header } from './'; -import { List, Map, Settings } from '../'; +import { List, Map, Settings, Stats } from '../'; import { Persistent, PersistentObject, PersistentSet } from '../../persist'; import './Chain.css'; export namespace Chain { - export type Display = 'list' | 'map' | 'settings' | 'consensus'; + export type Display = 'list' | 'map' | 'settings' | 'consensus' | 'stats'; export interface Props { appState: Readonly; @@ -93,16 +93,26 @@ export class Chain extends React.Component { const { appState, appUpdate, connection, pins, sortBy } = this.props; - return display === 'list' ? ( - - ) : ( - - ); + if (display === 'list') { + return ( + + ); + } + + if (display === 'map') { + return ; + } + + if (display === 'stats') { + return ; + } + + throw new Error('invalid `display`: ${display}'); } private setDisplay = (display: Chain.Display) => { diff --git a/frontend/src/components/Chain/Header.tsx b/frontend/src/components/Chain/Header.tsx index c53819a0..9561060d 100644 --- a/frontend/src/components/Chain/Header.tsx +++ b/frontend/src/components/Chain/Header.tsx @@ -28,6 +28,7 @@ import listIcon from '../../icons/list-alt-regular.svg'; import worldIcon from '../../icons/location.svg'; import settingsIcon from '../../icons/settings.svg'; import consensusIcon from '../../icons/cube-alt.svg'; +import statsIcon from '../../icons/graph.svg'; import './Header.css'; @@ -90,6 +91,14 @@ export class Header extends React.Component { current={currentTab} setDisplay={setDisplay} /> + . +*/ + +.Stats { + text-align: center; + padding-top: 2.5rem; + padding-bottom: 0.1rem; +} + +.Stats-category { + text-align: left; + background-color: #fff; + margin-bottom: 2.5rem; + padding: 1rem; +} + +.Stats-category table { + color: #000; + width: 100%; + table-layout: fixed; + border-collapse: collapse; +} + +.Stats-category tr:nth-child(even) { + background-color: #eee; +} + +.Stats-percent { + width: 6em; + text-align: right; + padding-left: 0.5rem; + padding-right: 1rem; +} + +.Stats-count { + width: 6.5em; + text-align: right; + padding-right: 1.5rem; + border-right: 1px solid black; +} + +.Stats-value { + padding-left: 2rem; +} + +th.Stats-value { + padding-left: 1rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.Stats-category td { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.Stats-unknown { + opacity: 0.5; +} diff --git a/frontend/src/components/Stats/Stats.tsx b/frontend/src/components/Stats/Stats.tsx new file mode 100644 index 00000000..93139867 --- /dev/null +++ b/frontend/src/components/Stats/Stats.tsx @@ -0,0 +1,204 @@ +// Source code for the Substrate Telemetry Server. +// Copyright (C) 2022 Parity Technologies (UK) Ltd. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import * as React from 'react'; +import { Maybe } from '../../common'; +import { State as AppState } from '../../state'; +import { Row } from '../List'; +import { PersistentObject } from '../../persist'; +import { Ranking, Range } from '../../common/types'; + +import './Stats.css'; + +export namespace Stats { + export type Display = 'list' | 'map' | 'Stats'; + + export interface Props { + appState: Readonly; + } +} + +function displayPercentage(percent: number): string { + return (Math.round(percent * 100) / 100).toFixed(2); +} + +function generateRankingTable( + key: string, + label: string, + format: (value: T) => string, + ranking: Ranking +) { + let total = ranking.other + ranking.unknown; + ranking.list.forEach(([_, count]) => { + total += count; + }); + + if (ranking.unknown === total) { + return null; + } + + const entries: React.ReactNode[] = []; + ranking.list.forEach(([value, count]) => { + const percent = displayPercentage((count / total) * 100); + const index = entries.length; + entries.push( + + {percent}% + {count} + {format(value)} + + ); + }); + + if (ranking.other > 0) { + const percent = displayPercentage((ranking.other / total) * 100); + entries.push( + + {percent}% + {ranking.other} + Other + + ); + } + + if (ranking.unknown > 0) { + const percent = displayPercentage((ranking.unknown / total) * 100); + entries.push( + + {percent}% + {ranking.unknown} + Unknown + + ); + } + + return ( +
+ + + + + + + {entries} +
+ + {label}
+
+ ); +} + +function identity(value: string | number): string { + return value + ''; +} + +function formatMemory(value: Range): string { + const [min, max] = value; + if (min === 0) { + return 'Less than ' + max + ' GB'; + } + if (max === null) { + return 'At least ' + min + ' GB'; + } + return min + ' GB'; +} + +function formatYesNo(value: boolean): string { + if (value) { + return 'Yes'; + } else { + return 'No'; + } +} + +function formatScore(value: Range): string { + const [min, max] = value; + if (max === null) { + return 'More than ' + (min / 100).toFixed(1) + 'x'; + } + if (min === 0) { + return 'Less than ' + (max / 100).toFixed(1) + 'x'; + } + if (min <= 100 && max >= 100) { + return 'Baseline'; + } + return (min / 100).toFixed(1) + 'x'; +} + +export class Stats extends React.Component { + public render() { + const { appState } = this.props; + + const children: React.ReactNode[] = []; + function add( + key: string, + label: string, + format: (value: T) => string, + ranking: Maybe> + ) { + if (ranking) { + const child = generateRankingTable(key, label, format, ranking); + if (child !== null) { + children.push(child); + } + } + } + + const stats = appState.chainStats; + if (stats) { + add('version', 'Version', identity, stats.version); + add('target_os', 'Operating System', identity, stats.target_os); + add('target_arch', 'CPU Architecture', identity, stats.target_arch); + add('cpu', 'CPU', identity, stats.cpu); + add('core_count', 'CPU Cores', identity, stats.core_count); + add('memory', 'Memory', formatMemory, stats.memory); + add( + 'is_virtual_machine', + 'Is Virtual Machine?', + formatYesNo, + stats.is_virtual_machine + ); + add('linux_distro', 'Linux Distribution', identity, stats.linux_distro); + add('linux_kernel', 'Linux Kernel', identity, stats.linux_kernel); + add( + 'cpu_hashrate_score', + 'CPU Speed', + formatScore, + stats.cpu_hashrate_score + ); + add( + 'memory_memcpy_score', + 'Memory Speed', + formatScore, + stats.memory_memcpy_score + ); + add( + 'disk_sequential_write_score', + 'Disk Speed (sequential writes)', + formatScore, + stats.disk_sequential_write_score + ); + add( + 'disk_random_write_score', + 'Disk Speed (random writes)', + formatScore, + stats.disk_random_write_score + ); + } + + return
{children}
; + } +} diff --git a/frontend/src/components/Stats/index.ts b/frontend/src/components/Stats/index.ts new file mode 100644 index 00000000..e008d51a --- /dev/null +++ b/frontend/src/components/Stats/index.ts @@ -0,0 +1,17 @@ +// Source code for the Substrate Telemetry Server. +// Copyright (C) 2022 Parity Technologies (UK) Ltd. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +export * from './Stats'; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 1a088539..bfd38f39 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -20,6 +20,7 @@ export * from './Chain'; export * from './List'; export * from './Map'; export * from './Settings'; +export * from './Stats'; export * from './Icon'; export * from './Tile'; export * from './Ago'; diff --git a/frontend/src/state.ts b/frontend/src/state.ts index 2ba1e7da..eb16b8f0 100644 --- a/frontend/src/state.ts +++ b/frontend/src/state.ts @@ -279,6 +279,7 @@ export interface State { pins: Readonly>; sortBy: Readonly>; selectedColumns: Column[]; + chainStats: Maybe; } export type Update = (