Skip to main content

hermod/server/
node.rs

1//! Per-node state and shared tracer state
2//!
3//! Every Cardano node that connects to `hermod-tracer` gets a [`NodeState`]
4//! instance, which holds:
5//!
6//! - A unique [`NodeId`] (the socket path or `ip:port` of the connection)
7//! - A URL-safe [`NodeSlug`] derived from the node's display name for Prometheus routes
8//! - A dedicated [`prometheus::Registry`] that accumulates EKG metrics for
9//!   that node
10//! - The connection timestamp
11//!
12//! All active nodes are tracked in the shared [`TracerState`], which is
13//! `Arc`-cloned across every connection-handling task.
14
15use crate::server::config::TracerConfig;
16use indexmap::IndexMap;
17use prometheus::{GaugeVec, Registry};
18use std::collections::HashMap;
19use std::sync::{Arc, Mutex};
20use std::time::Instant;
21use tokio::sync::RwLock;
22
23/// Unique identifier for a connected node (socket path or ip:port)
24pub type NodeId = String;
25
26/// URL-safe slug derived from the node's display name, used as Prometheus route segment
27pub type NodeSlug = String;
28
29/// All state associated with one connected node
30pub struct NodeState {
31    /// The node's connection address (internal key — not shown to users)
32    pub id: NodeId,
33    /// Human-friendly display name from the node's `NodeInfo` DataPoint
34    /// (`niName`). Falls back to the raw `NodeId` if the DataPoint request
35    /// fails or returns an empty name.
36    pub name: String,
37    /// URL-safe slug derived from `name`, used in Prometheus routes and as
38    /// the log subdirectory name
39    pub slug: NodeSlug,
40    /// This node's dedicated Prometheus registry
41    pub registry: Arc<Registry>,
42    /// When this node connected
43    pub connected_at: Instant,
44    /// Cache of Prometheus gauges derived from incoming trace object fields
45    pub trace_gauge_cache: Mutex<HashMap<String, GaugeVec>>,
46}
47
48impl NodeState {
49    /// Create new node state.
50    ///
51    /// `id` is the connection address (internal key).
52    /// `name` is the display name (from `NodeInfo.niName`, fallback to `id`).
53    pub fn new(id: NodeId, name: String) -> Self {
54        let slug = slugify(&name);
55        let registry = Arc::new(Registry::new());
56        NodeState {
57            id,
58            name,
59            slug,
60            registry,
61            connected_at: Instant::now(),
62            trace_gauge_cache: Mutex::new(HashMap::new()),
63        }
64    }
65}
66
67/// State shared across all connections
68pub struct TracerState {
69    /// All currently-connected nodes, keyed by NodeId
70    pub nodes: RwLock<IndexMap<NodeId, Arc<NodeState>>>,
71    /// The loaded configuration
72    pub config: Arc<TracerConfig>,
73}
74
75impl TracerState {
76    /// Create a new empty tracer state
77    pub fn new(config: Arc<TracerConfig>) -> Self {
78        TracerState {
79            nodes: RwLock::new(IndexMap::new()),
80            config,
81        }
82    }
83
84    /// Register a node; returns the new NodeState.
85    ///
86    /// `name` is the display name (from `NodeInfo.niName`).  Pass the same
87    /// value as `id` when no name has been resolved yet.
88    pub async fn register(&self, id: NodeId, name: String) -> Arc<NodeState> {
89        let node = Arc::new(NodeState::new(id.clone(), name));
90        self.nodes.write().await.insert(id, node.clone());
91        node
92    }
93
94    /// Remove a node by ID
95    pub async fn deregister(&self, id: &NodeId) {
96        self.nodes.write().await.shift_remove(id);
97    }
98
99    /// Get a snapshot of connected nodes as (name, slug) pairs.
100    ///
101    /// `name` is the human-friendly display name (from `NodeInfo.niName`);
102    /// `slug` is the URL-safe Prometheus route segment derived from it.
103    pub async fn node_list(&self) -> Vec<(String, NodeSlug)> {
104        self.nodes
105            .read()
106            .await
107            .values()
108            .map(|n| (n.name.clone(), n.slug.clone()))
109            .collect()
110    }
111
112    /// Look up a node by slug
113    pub async fn find_by_slug(&self, slug: &str) -> Option<Arc<NodeState>> {
114        self.nodes
115            .read()
116            .await
117            .values()
118            .find(|n| n.slug == slug)
119            .cloned()
120    }
121
122    /// Return all currently-connected nodes
123    pub async fn all_nodes(&self) -> Vec<Arc<NodeState>> {
124        self.nodes.read().await.values().cloned().collect()
125    }
126}
127
128/// Convert an arbitrary string into a URL-safe slug:
129/// lowercase, replace non-alphanumeric chars with `-`, collapse runs of `-`.
130pub fn slugify(s: &str) -> String {
131    let raw: String = s
132        .to_lowercase()
133        .chars()
134        .map(|c| if c.is_alphanumeric() { c } else { '-' })
135        .collect();
136
137    // Collapse consecutive dashes and trim leading/trailing dashes
138    let mut result = String::with_capacity(raw.len());
139    let mut last_was_dash = true; // skip leading dashes
140    for c in raw.chars() {
141        if c == '-' {
142            if !last_was_dash {
143                result.push('-');
144                last_was_dash = true;
145            }
146        } else {
147            result.push(c);
148            last_was_dash = false;
149        }
150    }
151    // Trim trailing dash
152    if result.ends_with('-') {
153        result.pop();
154    }
155    if result.is_empty() {
156        result.push('x');
157    }
158    result
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::server::config::TracerConfig;
165
166    fn make_config() -> Arc<TracerConfig> {
167        Arc::new(
168            TracerConfig::from_yaml(
169                r#"
170networkMagic: 42
171network:
172  tag: AcceptAt
173  contents: "/tmp/hermod.sock"
174logging:
175- logRoot: "/tmp"
176  logMode: FileMode
177  logFormat: ForMachine
178"#,
179            )
180            .unwrap(),
181        )
182    }
183
184    // --- slugify ---
185
186    #[test]
187    fn test_slugify_unix_path() {
188        assert_eq!(slugify("/tmp/forwarder.sock"), "tmp-forwarder-sock");
189    }
190
191    #[test]
192    fn test_slugify_tcp() {
193        assert_eq!(slugify("192.168.1.1:3000"), "192-168-1-1-3000");
194    }
195
196    #[test]
197    fn test_slugify_already_clean() {
198        assert_eq!(slugify("mynode"), "mynode");
199    }
200
201    #[test]
202    fn test_slugify_empty_becomes_x() {
203        assert_eq!(slugify("!!!"), "x");
204    }
205
206    #[test]
207    fn slugify_collapses_consecutive_separators() {
208        assert_eq!(slugify("a---b"), "a-b");
209        assert_eq!(slugify("--leading"), "leading");
210        assert_eq!(slugify("trailing--"), "trailing");
211    }
212
213    #[test]
214    fn slugify_uppercased_is_lowercased() {
215        assert_eq!(slugify("MyNode"), "mynode");
216    }
217
218    // --- NodeState ---
219
220    #[test]
221    fn node_state_slug_derived_from_name() {
222        let node = NodeState::new("conn-id".to_string(), "My Node".to_string());
223        assert_eq!(node.slug, "my-node");
224        assert_eq!(node.name, "My Node");
225        assert_eq!(node.id, "conn-id");
226    }
227
228    // --- TracerState ---
229
230    #[tokio::test]
231    async fn register_and_deregister_node() {
232        let state = TracerState::new(make_config());
233        state
234            .register("node1".to_string(), "Node One".to_string())
235            .await;
236        assert_eq!(state.node_list().await.len(), 1);
237        state.deregister(&"node1".to_string()).await;
238        assert_eq!(state.node_list().await.len(), 0);
239    }
240
241    #[tokio::test]
242    async fn find_by_slug_returns_correct_node() {
243        let state = TracerState::new(make_config());
244        state
245            .register("node1".to_string(), "My Node".to_string())
246            .await;
247        let found = state.find_by_slug("my-node").await;
248        assert!(found.is_some());
249        assert_eq!(found.unwrap().name, "My Node");
250    }
251
252    #[tokio::test]
253    async fn find_by_slug_missing_returns_none() {
254        let state = TracerState::new(make_config());
255        assert!(state.find_by_slug("nonexistent").await.is_none());
256    }
257
258    #[tokio::test]
259    async fn node_list_returns_name_and_slug_pairs() {
260        let state = TracerState::new(make_config());
261        state.register("n1".to_string(), "Alpha".to_string()).await;
262        state.register("n2".to_string(), "Beta".to_string()).await;
263        let list = state.node_list().await;
264        assert_eq!(list.len(), 2);
265        assert!(
266            list.iter()
267                .any(|(name, slug)| name == "Alpha" && slug == "alpha")
268        );
269        assert!(
270            list.iter()
271                .any(|(name, slug)| name == "Beta" && slug == "beta")
272        );
273    }
274
275    #[tokio::test]
276    async fn all_nodes_returns_arc_node_states() {
277        let state = TracerState::new(make_config());
278        state.register("n1".to_string(), "One".to_string()).await;
279        let all = state.all_nodes().await;
280        assert_eq!(all.len(), 1);
281        assert_eq!(all[0].name, "One");
282    }
283
284    #[tokio::test]
285    async fn register_overwrites_existing_node_with_same_id() {
286        let state = TracerState::new(make_config());
287        state.register("n1".to_string(), "First".to_string()).await;
288        state.register("n1".to_string(), "Second".to_string()).await;
289        let list = state.node_list().await;
290        assert_eq!(list.len(), 1);
291        assert_eq!(list[0].0, "Second");
292    }
293}