Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add: node reachabillity #36

Merged
merged 5 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 105 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,34 @@ async fn main() -> Result<(), MainError> {
},
));

// pupulate cache with available data
let forks = headertree::recent_forks(&tree, MAX_FORKS_IN_CACHE).await;
let header_infos_json =
headertree::strip_tree(&tree, network.max_interesting_heights, BTreeSet::new()).await;
{
let mut locked_caches = caches.lock().await;
let node_data: NodeData = network
.nodes
.iter()
.cloned()
.map(|n| {
(
n.info().id,
NodeDataJson::new(
n.info(),
&vec![], // no chain tips knows yet
VERSION_UNKNOWN.to_string(), // is updated later, when we know it
0, // timestamp of last block update
true, // assume the node is reachable, if it isn't we set it to false after the first getchaintips RPC call anyway
),
)
})
.collect();
locked_caches.insert(
network.id,
Cache {
header_infos_json,
node_data: BTreeMap::new(),
node_data,
forks,
},
);
Expand Down Expand Up @@ -178,7 +196,10 @@ async fn main() -> Result<(), MainError> {
continue;
};
let tips = match node.tips().await {
Ok(tips) => tips,
Ok(tips) => {
node_reachable(&caches_clone, network.id, node.info().id, true).await;
tips
}
Err(e) => {
error!(
"Could not fetch chaintips from {} on network '{}' (id={}): {:?}",
Expand All @@ -187,6 +208,7 @@ async fn main() -> Result<(), MainError> {
network.id,
e
);
node_reachable(&caches_clone, network.id, node.info().id, false).await;
continue;
}
};
Expand Down Expand Up @@ -304,10 +326,12 @@ async fn main() -> Result<(), MainError> {
&relevant_tips,
version_info.clone(),
last_change_timestamp,
true, // here, the node was reachable
);

let forks = headertree::recent_forks(&tree_clone, MAX_FORKS_IN_CACHE).await;
{
// update node cache with new state data
let mut locked_cache = caches_clone.lock().await;
let this_network = locked_cache
.get(&network.id)
Expand Down Expand Up @@ -539,6 +563,13 @@ async fn main() -> Result<(), MainError> {
.and(rss::with_rss_base_url(config.rss_base_url.clone()))
.and_then(rss::lagging_nodes_response);

let unreachable_nodes_rss = warp::get()
.and(warp::path!("rss" / u32 / "unreachable.xml"))
.and(api::with_caches(caches.clone()))
.and(api::with_networks(network_infos.clone()))
.and(rss::with_rss_base_url(config.rss_base_url.clone()))
.and_then(rss::unreachable_nodes_response);

let networks_json = warp::get()
.and(warp::path!("api" / "networks.json"))
.and(api::with_networks(network_infos))
Expand Down Expand Up @@ -568,12 +599,23 @@ async fn main() -> Result<(), MainError> {
.or(change_sse)
.or(forks_rss)
.or(lagging_nodes_rss)
.or(unreachable_nodes_rss)
.or(invalid_blocks_rss);

warp::serve(routes).run(config.address).await;
Ok(())
}

async fn node_reachable(caches: &Caches, network_id: u32, node_id: u32, reachable: bool) {
let mut locked_cache = caches.lock().await;
locked_cache.entry(network_id).and_modify(|network| {
network
.node_data
.entry(node_id)
.and_modify(|e| e.reachable(reachable));
});
}

// Find out for which heights we have tips for. These are
// interesting to us - we don't want strip them from the tree.
// This includes tips that aren't from a fork, but rather from
Expand All @@ -595,3 +637,64 @@ async fn tip_heights(network_id: u32, caches: &Caches) -> BTreeSet<u64> {
}
tip_heights
}

#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeInfo;

async fn get_test_node_reachable(caches: &Caches, net_id: u32, node_id: u32) -> bool {
let locked_caches = caches.lock().await;
locked_caches
.get(&net_id)
.expect("network id should be there")
.node_data
.get(&node_id)
.expect("node id should be there")
.reachable
}

#[tokio::test]
async fn test_node_reachable() {
let network_id: u32 = 0;
let caches: Caches = Arc::new(Mutex::new(BTreeMap::new()));
let node = NodeInfo {
id: 0,
name: "".to_string(),
description: "".to_string(),
};
{
// populate data
let mut locked_caches = caches.lock().await;
let mut node_data: NodeData = BTreeMap::new();
node_data.insert(
node.id,
NodeDataJson::new(node.clone(), &vec![], "".to_string(), 0, true),
);
locked_caches.insert(
network_id,
Cache {
header_infos_json: vec![],
node_data,
forks: vec![],
},
);
}
assert_eq!(
get_test_node_reachable(&caches, network_id, node.id).await,
true
);

node_reachable(&caches, network_id, node.id, false).await;
assert_eq!(
get_test_node_reachable(&caches, network_id, node.id).await,
false
);

node_reachable(&caches, network_id, node.id, true).await;
assert_eq!(
get_test_node_reachable(&caches, network_id, node.id).await,
true
);
}
}
62 changes: 62 additions & 0 deletions src/rss.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,17 @@ impl Item {
guid: format!("lagging-node-{}-on-{}", node.name, height),
}
}

pub fn unreachable_node_item(node: &NodeDataJson) -> Item {
Item {
title: format!("Node '{}' (id={}) is unreachable", node.name, node.id),
description: format!(
"The RPC server of this node is not reachable. The node might be offline or there might be other networking issues. The nodes tip data was last updated at timestamp {} (zero indicates never).",
node.last_changed_timestamp,
),
guid: format!("unreachable-node-{}-last-{}", node.id, node.last_changed_timestamp),
}
}
}

pub async fn lagging_nodes_response(
Expand Down Expand Up @@ -323,6 +334,57 @@ pub async fn invalid_blocks_response(
}
}

pub async fn unreachable_nodes_response(
network_id: u32,
caches: Caches,
network_infos: Vec<NetworkJson>,
base_url: String,
) -> Result<impl warp::Reply, Infallible> {
let caches_locked = caches.lock().await;

match caches_locked.get(&network_id) {
Some(cache) => {
let mut network_name = "";
if let Some(network) = network_infos
.iter()
.filter(|net| net.id == network_id)
.collect::<Vec<&NetworkJson>>()
.first()
{
network_name = &network.name;
}

let unreachable_node_items: Vec<Item> = cache
.node_data
.values()
.filter(|node| !node.reachable)
.map(|node| Item::unreachable_node_item(node))
.collect();
let feed = Feed {
channel: Channel {
title: format!("Unreachable nodes - {}", network_name),
description: format!(
"Nodes on the {} network that can't be reached",
network_name
),
link: format!(
"{}?network={}?src=unreachable-nodes",
base_url.clone(),
network_id
),
href: format!("{}/rss/{}/unreachable.xml", base_url, network_id),
items: unreachable_node_items,
},
};

return Ok(Response::builder()
.header("content-type", "application/rss+xml")
.body(feed.to_string()));
}
None => Ok(Ok(response_unknown_network(network_infos))),
}
}

pub fn response_unknown_network(network_infos: Vec<NetworkJson>) -> Response<String> {
let avaliable_networks = network_infos
.iter()
Expand Down
8 changes: 8 additions & 0 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ pub struct NodeDataJson {
pub last_changed_timestamp: u64,
/// The node subversion as advertised by the node on the network.
pub version: String,
/// If the last getchaintips RPC reached the node.
pub reachable: bool,
}

impl NodeDataJson {
Expand All @@ -152,6 +154,7 @@ impl NodeDataJson {
tips: &Vec<ChainTip>,
version: String,
last_changed_timestamp: u64,
reachable: bool,
) -> Self {
NodeDataJson {
id: info.id,
Expand All @@ -160,8 +163,13 @@ impl NodeDataJson {
tips: tips.iter().map(TipInfoJson::new).collect(),
last_changed_timestamp,
version,
reachable,
}
}

pub fn reachable(&mut self, r: bool) {
self.reachable = r;
}
}

#[derive(Serialize, Clone)]
Expand Down
4 changes: 4 additions & 0 deletions www/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ <h3>Network: <span id="network_info_name"></span></h3>
<img src="static/img/rss-feed-white.svg" height=18>
<a target="_blank" id="rss_lagging_nodes">Lagging nodes</a>
</span>
<span>
<img src="static/img/rss-feed-white.svg" height=18>
<a target="_blank" id="rss_unreachable_nodes">Unreachable nodes</a>
</span>
</p>
<br>
<details style="color: var(--text-color);" open>
Expand Down
84 changes: 64 additions & 20 deletions www/js/blocktree.js
Original file line number Diff line number Diff line change
Expand Up @@ -381,45 +381,89 @@ function node_description_summary(description) {
return description
}

function get_active_height_or_0(node) {
let active_tips = node.tips.filter(tip => tip.status == "active")
if (active_tips.length > 0) {
return active_tips[0].height
}
return 0
}

function get_active_hash_or_fake(node) {
let active_tips = node.tips.filter(tip => tip.status == "active")
if (active_tips.length > 0) {
return active_tips[0].hash
}
return "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdead"
}

function ago(timestamp) {
const rtf = new Intl.RelativeTimeFormat("en", {
style: "narrow",
numeric: "always",
});
const now = new Date()
const utc_seconds = (now.getTime() + now.getTimezoneOffset()*60) / 1000;
const seconds = timestamp - utc_seconds;
if (seconds > -90) {
return rtf.format(seconds, "seconds");
}
const minutes = parseInt(seconds/60);
if (minutes > -60) {
return rtf.format(minutes, "minutes");
}
const hours = parseInt(minutes/60);
if (hours > -24) {
return rtf.format(hours, "hours");
}
const days = parseInt(hours/60);
if (days > -30) {
return rtf.format(days, "days");
}
const months = parseInt(days/31);
if (months > -12) {
return rtf.format(months, "months");
}

return "a long time ago"
}

async function draw_nodes() {
nodeInfoRow.html(null);
nodeInfoRow.selectAll('.node-info')
.data(state_data.nodes.sort((a, b) => a.tips.filter(tip => tip.status == "active")[0].height - b.tips.filter(tip => tip.status == "active")[0].height))
.data(state_data.nodes.sort((a, b) => get_active_height_or_0(a) - get_active_height_or_0(b)))
.enter()
.append("div")
.attr("class", "row-cols-auto px-1")
.html(d => `
<div class="col border rounded node-info my-1" style="min-height: 10rem; width: 16rem;">
<div class="m-2">
<h5 class="card-title py-0 my-0">
<span class="d-inline-block text-truncate" style="max-width: 15rem;">
<img class="invert" src="static/img/node.svg" height=28 alt="Node symbol">
${d.name}
</span>
</h5>
<span>
${node_description_summary(d.description)}
<div class="col border rounded node-info" style="min-height: 8rem; width: 16rem;">
<h5 class="card-title py-0 mt-1 mb-0">
<span class="mx-2 mt-1 d-inline-block text-truncate" style="max-width: 15rem;">
<img class="invert" src="static/img/node.svg" height=28 alt="Node symbol">
${d.name}
</span>
</h5>
<div class="px-2 small">
${d.reachable ? "": "<span class='badge text-bg-danger'>RPC unreachable</span>"}
<span class='badge text-bg-secondary small'>${d.version.replaceAll("/", "").replaceAll("Satoshi:", "")}</span>
<span class="badge text-bg-secondary small">tip changed ${ago(d.last_changed_timestamp)}</span>
</div>

<div class="px-2">
<span class="small">version: ${d.version}
</div>
<div class="px-2">
<span class="small">changed: ${new Date(d.last_changed_timestamp * 1000).toLocaleTimeString()}
${node_description_summary(d.description)}
</div>
<div class="px-2" style="background-color: hsl(${parseInt(d.tips.filter(tip => tip.status == "active")[0].height * 90, 10) % 360}, 50%, 75%)">
<span class="small text-color-dark"> height: ${d.tips.filter(tip => tip.status == "active")[0].height}
<div class="px-2" style="background-color: hsl(${parseInt(get_active_height_or_0(d) * 90, 10) % 360}, 50%, 75%)">
<span class="small text-color-dark"> height: ${get_active_height_or_0(d)}
</div>
<div class="px-2" style="background-color: hsl(${parseInt(d.tips.filter(tip => tip.status == "active")[0].hash.substring(58), 16) % 360}, 50%, 75%)">
<div class="px-2 rounded-bottom" style="background-color: hsl(${(parseInt(get_active_hash_or_fake(d).substring(58), 16) + 120) % 360}, 50%, 75%)">
<details>
<summary style="list-style: none;">
<span class="small text-color-dark">
tip: …${d.tips.filter(tip => tip.status == "active")[0].hash.substring(54, 64)}
tip hash: …${get_active_hash_or_fake(d).substring(54, 64)}
</span>
</summary>
<span class="small text-color-dark">
${d.tips.filter(tip => tip.status == "active")[0].hash}
${get_active_hash_or_fake(d)}
</span>
</details>
</div>
Expand Down
Loading
Loading