diff --git a/Cargo.lock b/Cargo.lock index c1f4076..50fbb18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2268,7 +2268,7 @@ dependencies = [ [[package]] name = "kaspa-resolver" -version = "0.8.0" +version = "0.10.1" dependencies = [ "ahash", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index b3fd47b..6fa8b45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "kaspa-resolver" description = "Kaspa RPC endpoint resolver" -version = "0.8.0" +version = "0.10.1" edition = "2021" # authors.workspace = true # include.workspace = true diff --git a/data/resolver.3.bin b/data/resolver.3.bin new file mode 100644 index 0000000..c90d00d Binary files /dev/null and b/data/resolver.3.bin differ diff --git a/src/args.rs b/src/args.rs index 05b7687..67c7d90 100644 --- a/src/args.rs +++ b/src/args.rs @@ -30,6 +30,8 @@ pub struct Args { pub auto_update: bool, /// Custom config file pub user_config: Option, + /// public status page + pub public: bool, // Show node data on each election // pub election: bool, // Enable resolver status access via `/status` @@ -39,6 +41,10 @@ pub struct Args { } impl Args { + pub fn public(&self) -> bool { + self.public + } + pub fn parse() -> Args { #[allow(unused)] use clap::{arg, command, Arg, Command}; @@ -49,6 +55,7 @@ impl Args { )) .arg(arg!(--version "Display software version")) .arg(arg!(--verbose "Enable verbose logging")) + .arg(arg!(--public "Enable public status page")) .arg(arg!(--trace "Enable trace log level")) .arg(arg!(--debug "Enable additional debug output")) // .arg(arg!(--auto-update "Poll configuration updates")) @@ -94,6 +101,7 @@ impl Args { let matches = cmd.get_matches(); + let public = matches.get_one::("public").cloned().unwrap_or(false); let trace = matches.get_one::("trace").cloned().unwrap_or(false); let verbose = matches.get_one::("verbose").cloned().unwrap_or(false); let debug = matches.get_one::("debug").cloned().unwrap_or(false); @@ -173,6 +181,7 @@ impl Args { debug, auto_update, user_config, + public, // election, // status, listen, diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..f1e354c --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,39 @@ +use axum::{ + body::Body, + http::{header, HeaderValue}, + response::{IntoResponse, Response}, +}; + +#[derive(Clone, Copy, Debug)] +#[must_use] +pub struct NoCacheHtml(pub T); + +impl IntoResponse for NoCacheHtml +where + T: Into, +{ + fn into_response(self) -> Response { + ( + [ + ( + header::CONTENT_TYPE, + HeaderValue::from_static(mime::TEXT_HTML_UTF_8.as_ref()), + ), + ( + header::CACHE_CONTROL, + HeaderValue::from_static( + "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0", + ), + ), + ], + self.0.into(), + ) + .into_response() + } +} + +impl From for NoCacheHtml { + fn from(inner: T) -> Self { + Self(inner) + } +} diff --git a/src/config.rs b/src/config.rs index 46b46f3..64eed83 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,7 +3,7 @@ use std::sync::LazyLock; use crate::imports::*; use chrono::prelude::*; -const VERSION: u64 = 2; +const VERSION: u64 = 3; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Config { diff --git a/src/connection.rs b/src/connection.rs index d80d10d..ecf7a35 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -461,88 +461,3 @@ impl<'a> From<&'a Arc> for Output<'a> { } } } - -#[derive(Serialize)] -pub struct Status<'a> { - pub version: String, - #[serde(with = "SerHex::")] - pub sid: u64, - #[serde(with = "SerHex::")] - pub uid: u64, - pub url: &'a str, - pub fqdn: &'a str, - pub service: String, - // pub service: &'a str, - pub protocol: ProtocolKind, - pub encoding: EncodingKind, - pub encryption: TlsKind, - pub network: &'a NetworkId, - pub cores: u64, - pub memory: u64, - pub status: &'static str, - pub peers: u64, - pub clients: u64, - pub capacity: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub delegates: Option>, -} - -impl<'a> From<&'a Arc> for Status<'a> { - fn from(connection: &'a Arc) -> Self { - let delegate = connection.delegate(); - - let node = connection.node(); - let uid = node.uid(); - let url = node.address.as_str(); - let fqdn = node.fqdn.as_str(); - let service = node.service().to_string(); - let protocol = node.params().protocol(); - let encoding = node.params().encoding(); - let encryption = node.params().tls(); - let network = &node.network; - let status = connection.status(); - let clients = delegate.clients(); - let peers = delegate.peers(); - let (version, sid, capacity, cores, memory) = delegate - .caps() - .as_ref() - .as_ref() - .map(|caps| { - ( - caps.version.clone(), - caps.system_id, - caps.clients_limit, - caps.cpu_physical_cores, - caps.total_memory, - ) - }) - .unwrap_or_else(|| ("n/a".to_string(), 0, 0, 0, 0)); - - let delegates = connection - .resolve_delegators() - .iter() - .map(|connection| format!("[{:016x}] {}", connection.system_id(), connection.address())) - .collect::>(); - let delegates = (!delegates.is_empty()).then_some(delegates); - - Self { - sid, - uid, - version, - fqdn, - service, - url, - protocol, - encoding, - encryption, - network, - cores, - memory, - status, - clients, - peers, - capacity, - delegates, - } - } -} diff --git a/src/imports.rs b/src/imports.rs index 2a6d05a..3884c9d 100644 --- a/src/imports.rs +++ b/src/imports.rs @@ -1,6 +1,7 @@ pub use crate::args::Args; +pub use crate::cache::NoCacheHtml; pub use crate::config::*; -pub use crate::connection::{Connection, Output, Status}; +pub use crate::connection::{Connection, Output}; pub use crate::delegate::*; pub use crate::error::Error; pub use crate::events::Events; @@ -10,6 +11,7 @@ pub use crate::monitor::Monitor; pub use crate::node::*; pub use crate::params::PathParams; pub use crate::path::*; +pub(crate) use crate::public; pub use crate::resolver::Resolver; pub use crate::result::Result; pub(crate) use crate::rpc; diff --git a/src/main.rs b/src/main.rs index e61400b..9b2bc57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod args; +mod cache; mod config; mod connection; mod delegate; @@ -12,6 +13,7 @@ mod node; mod panic; mod params; mod path; +mod public; mod resolver; mod result; mod rpc; diff --git a/src/public.rs b/src/public.rs new file mode 100644 index 0000000..3b159f1 --- /dev/null +++ b/src/public.rs @@ -0,0 +1,104 @@ +use crate::imports::*; +use askama::Template; + +use axum::{ + body::Body, + http::{header, HeaderValue, Request, StatusCode}, + response::{IntoResponse, Response}, +}; + +#[derive(Template)] +#[template(path = "public.html", escape = "none")] +struct PublicTemplate {} + +pub async fn json_handler(resolver: &Arc, _req: Request) -> impl IntoResponse { + let connections = resolver.connections(); + let connections = connections + .iter() + // .filter(|c| c.is_delegate()) + .map(Public::from) + .collect::>(); + let nodes = serde_json::to_string(&connections).unwrap(); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .header( + header::CACHE_CONTROL, + HeaderValue::from_static( + "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0", + ), + ) + .body(Body::from(nodes)) + .unwrap() +} + +pub async fn status_handler(_resolver: &Arc, _req: Request) -> impl IntoResponse { + let index = PublicTemplate {}; + + Response::builder() + .status(StatusCode::OK) + .header( + header::CACHE_CONTROL, + HeaderValue::from_static( + "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0", + ), + ) + .body(Body::from(index.render().unwrap())) + .unwrap() +} + +#[derive(Serialize)] +pub struct Public<'a> { + pub version: String, + #[serde(with = "SerHex::")] + pub sid: u64, + #[serde(with = "SerHex::")] + pub uid: u64, + pub service: String, + pub protocol: ProtocolKind, + pub encoding: EncodingKind, + pub encryption: TlsKind, + pub network: &'a NetworkId, + pub status: &'static str, + pub peers: u64, + pub clients: u64, + pub capacity: u64, +} + +impl<'a> From<&'a Arc> for Public<'a> { + fn from(connection: &'a Arc) -> Self { + let delegate = connection.delegate(); + + let node = connection.node(); + let uid = node.uid(); + let service = node.service().to_string(); + let protocol = node.params().protocol(); + let encoding = node.params().encoding(); + let encryption = node.params().tls(); + let network = &node.network; + let status = connection.status(); + let clients = delegate.clients(); + let peers = delegate.peers(); + let (version, sid, capacity) = delegate + .caps() + .as_ref() + .as_ref() + .map(|caps| (caps.version.clone(), caps.system_id, caps.clients_limit)) + .unwrap_or_else(|| ("n/a".to_string(), 0, 0)); + + Self { + sid, + uid, + version, + service, + protocol, + encoding, + encryption, + network, + status, + clients, + peers, + capacity, + } + } +} diff --git a/src/resolver.rs b/src/resolver.rs index a6ac6cc..706ad42 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -98,6 +98,20 @@ impl Resolver { get(|req: Request| async move { status::json_handler(&this, req).await }), ); + if self.args().public() { + let this = self.clone(); + router = router.route( + "/", + get(|req: Request| async move { public::status_handler(&this, req).await }), + ); + + let this = self.clone(); + router = router.route( + "/json", + get(|req: Request| async move { public::json_handler(&this, req).await }), + ); + } + if let Some(rate_limit) = self.args().rate_limit.as_ref() { log_success!( "Limits", @@ -278,34 +292,6 @@ impl Resolver { } } - // respond with a JSON object containing the status of all nodes - #[allow(dead_code)] - fn get_status(&self, monitor: Option<&Monitor>, filter: F) -> impl IntoResponse - where - F: Fn(&&Arc) -> bool, - { - if let Some(monitor) = monitor { - let connections = monitor.to_vec(); - let status = connections - .iter() - .filter(filter) - .map(Status::from) - .collect::>(); - - with_json(status) - } else { - let kaspa = self.inner.kaspa.to_vec(); - let sparkle = self.inner.sparkle.to_vec(); - let status = kaspa - .iter() - .chain(sparkle.iter()) - .filter(filter) - .map(Status::from) - .collect::>(); - with_json(status) - }; - } - // // respond with a JSON object containing the status of all nodes pub fn connections(&self) -> Vec> { let kaspa = self.inner.kaspa.to_vec(); @@ -374,31 +360,6 @@ fn with_json_string(json: String) -> Response { .into_response() } -#[inline] -fn with_json(data: T) -> Response -where - T: Serialize, -{ - ( - StatusCode::OK, - [ - ( - header::CONTENT_TYPE, - HeaderValue::from_static(mime::APPLICATION_JSON.as_ref()), - ), - ( - header::CACHE_CONTROL, - HeaderValue::from_static( - "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0", - ), - ), - (header::CONNECTION, HeaderValue::from_static("close")), - ], - serde_json::to_string(&data).unwrap(), - ) - .into_response() -} - #[inline] #[allow(dead_code)] fn with_mime(body: impl Into, mime: &'static str) -> Response { diff --git a/src/status.rs b/src/status.rs index ef3639e..1462f6d 100644 --- a/src/status.rs +++ b/src/status.rs @@ -13,44 +13,11 @@ pub enum RequestKind { Post(Form>), } -#[derive(Clone, Copy, Debug)] -#[must_use] -pub struct NoCacheHtml(pub T); - -impl IntoResponse for NoCacheHtml -where - T: Into, -{ - fn into_response(self) -> Response { - ( - [ - ( - header::CONTENT_TYPE, - HeaderValue::from_static(mime::TEXT_HTML_UTF_8.as_ref()), - ), - ( - header::CACHE_CONTROL, - HeaderValue::from_static( - "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0", - ), - ), - ], - self.0.into(), - ) - .into_response() - } -} - -impl From for NoCacheHtml { - fn from(inner: T) -> Self { - Self(inner) - } -} - #[derive(Template)] #[template(path = "index.html", escape = "none")] struct IndexTemplate { access: bool, + version: &'static str, } pub async fn logout_handler(resolver: &Arc, req: Request) -> impl IntoResponse { @@ -153,7 +120,10 @@ pub async fn status_handler(resolver: &Arc, req: RequestKind) -> impl Ok((Some(session), cookie)) => { session.touch(); - let index = IndexTemplate { access: true }; + let index = IndexTemplate { + access: true, + version: crate::VERSION, + }; if let Some(cookie) = cookie { Response::builder() @@ -185,12 +155,103 @@ pub async fn status_handler(resolver: &Arc, req: RequestKind) -> impl .body(Body::from(msg)) .unwrap(), Err(Error::Unauthorized) => { - let index = IndexTemplate { access: false }; + let index = IndexTemplate { + access: false, + version: crate::VERSION, + }; NoCacheHtml(index.render().unwrap()).into_response() } _ => { - let index = IndexTemplate { access: false }; + let index = IndexTemplate { + access: false, + version: crate::VERSION, + }; NoCacheHtml(index.render().unwrap()).into_response() } } } + +#[derive(Serialize)] +pub struct Status<'a> { + pub version: String, + #[serde(with = "SerHex::")] + pub sid: u64, + #[serde(with = "SerHex::")] + pub uid: u64, + pub url: &'a str, + pub fqdn: &'a str, + pub service: String, + // pub service: &'a str, + pub protocol: ProtocolKind, + pub encoding: EncodingKind, + pub encryption: TlsKind, + pub network: &'a NetworkId, + pub cores: u64, + pub memory: u64, + pub status: &'static str, + pub peers: u64, + pub clients: u64, + pub capacity: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub delegates: Option>, +} + +impl<'a> From<&'a Arc> for Status<'a> { + fn from(connection: &'a Arc) -> Self { + let delegate = connection.delegate(); + + let node = connection.node(); + let uid = node.uid(); + let url = node.address.as_str(); + let fqdn = node.fqdn.as_str(); + let service = node.service().to_string(); + let protocol = node.params().protocol(); + let encoding = node.params().encoding(); + let encryption = node.params().tls(); + let network = &node.network; + let status = connection.status(); + let clients = delegate.clients(); + let peers = delegate.peers(); + let (version, sid, capacity, cores, memory) = delegate + .caps() + .as_ref() + .as_ref() + .map(|caps| { + ( + caps.version.clone(), + caps.system_id, + caps.clients_limit, + caps.cpu_physical_cores, + caps.total_memory, + ) + }) + .unwrap_or_else(|| ("n/a".to_string(), 0, 0, 0, 0)); + + let delegates = connection + .resolve_delegators() + .iter() + .map(|connection| format!("[{:016x}] {}", connection.system_id(), connection.address())) + .collect::>(); + let delegates = (!delegates.is_empty()).then_some(delegates); + + Self { + sid, + uid, + version, + fqdn, + service, + url, + protocol, + encoding, + encryption, + network, + cores, + memory, + status, + clients, + peers, + capacity, + delegates, + } + } +} diff --git a/templates/index.css b/templates/index.css index b7a2f23..0456f67 100644 --- a/templates/index.css +++ b/templates/index.css @@ -86,6 +86,10 @@ td { } .wide { - padding-left : 16px; + padding-left : 8px; padding-right: 8px; -} \ No newline at end of file +} + +.pre { + white-space: pre; +} diff --git a/templates/index.html b/templates/index.html index f13e817..480d7fc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -61,6 +61,8 @@ + + diff --git a/templates/public.js b/templates/public.js new file mode 100644 index 0000000..1c27fe4 --- /dev/null +++ b/templates/public.js @@ -0,0 +1,168 @@ +document.addEventListener('DOMContentLoaded', () => { + window.resolver = { + sort : "network", + nodes : [], + networks : { + "mainnet": true, + "testnet-10": true, + "testnet-11": true, + }, + }; + + table = document.createElement('table'); + document.body.appendChild(table); + + thead = document.createElement('thead'); + table.appendChild(thead); + thead.innerHTML = "SID:UIDSERVICEVERSIONNETWORKSTATUSPEERSCLIENTS / CAPLOAD"; + + tbody = document.createElement('tbody'); + tbody.id = "nodes"; + table.appendChild(tbody); + + fetchData(); +}); + +function pad(str, len) { + return str.length >= len ? str : ' '.repeat(len - str.length) + str; +} + +function fetchData() { + fetch('/json') + .then(response => response.json()) + .then(data => { + window.resolver.nodes = data; + render(); + setTimeout(fetchData, 7500); + }) + .catch(error => { + setTimeout(fetchData, 1000); + console.error('Error fetching data:', error); + }); +} + +function filter(node, ctx) { + if (!window.resolver.networks[node.network]) { + return "hidden"; + } else if (node.status == "offline" && !ctx.offline) { + return "hidden"; + } else if (node.status == "delegator" && !ctx.delegators) { + return "hidden"; + } else { + return node.status; + } +} + +function render() { + + let ctx = { + offline : true, + delegators : false, + }; + + let resort = false; + let tbody = document.getElementById("nodes"); + + const status = window.resolver.nodes + .filter((node) => node.status === 'online') + .reduce((acc, node) => { + const network = node.network; + if (!acc[network]) { + acc[network] = { clients: 0, capacity: 0, count : 0 }; + } + acc[network].clients += node.clients; + acc[network].capacity += node.capacity; + acc[network].count += 1; + return acc; + }, {}); + + if (window.resolver.sort != window.resolver.lastSort) { + resort = true; + window.resolver.lastSort = window.resolver.sort; + } + + window.resolver.nodes.forEach((node) => { + let { + version, + sid, + uid, + service, + url, + protocol, + encoding, + encryption, + network, + cores, + memory, + status, + peers, + clients, + capacity, + delegates, + } = node; + + let el = document.getElementById(uid); + if (!el) { + el = document.createElement('tr'); + el.id = uid; + el.setAttribute('data-sort', node.network); + tbody.appendChild(el); + resort = true; + } + el.className = filter(node, ctx); + + let load = (clients / capacity * 100.0).toFixed(2); + let peers_ = pad(peers.toLocaleString(),4); + let clients_ = pad(clients.toLocaleString(),6); + let capacity_ = pad(capacity.toLocaleString(),6); + el.innerHTML = `${sid}:${uid}${service}${version}${network}${status}`; + if (status != "offline") { + el.innerHTML += `${peers_}${clients_} / ${capacity_}${load}%`; + } + }); + + if (resort) { + sort(); + } + + let status_entries = Object.entries(status); + status_entries.sort(([a], [b]) => a.localeCompare(b)); + status_entries.forEach(([network, status]) => { + let el = document.getElementById(`${network}-data`); + if (!el) { + let tbody = document.getElementById("status"); + el = document.createElement('td'); + el.id = network; + el.innerHTML = `    `; + tbody.appendChild(el); + + if (window.resolver.networks[network] == undefined) { + window.resolver.networks[network] = true; + } + + document.getElementById(`${network}-filter`).addEventListener('change', () => { + window.resolver.networks[network] = document.getElementById(`${network}-filter`).checked; + render(); + }); + el = document.getElementById(`${network}-data`); + } + let load = (status.clients / status.capacity * 100.0).toFixed(2); + let count = status.count.toLocaleString(); + let clients = status.clients.toLocaleString(); + let capacity = status.capacity.toLocaleString(); + el.innerHTML = `(${count}) ${clients} / ${capacity}  ${load}%`; + }); + +} + +function sort() { + let tbody = document.getElementById("nodes"); + let rows = Array.from(tbody.getElementsByTagName('tr')); + rows.sort((a, b) => { + let aValue = a.getAttribute('data-sort'); + let bValue = b.getAttribute('data-sort'); + return aValue.localeCompare(bValue); + }); + tbody.innerHTML = ''; // Clear existing rows + rows.forEach(row => tbody.appendChild(row)); +}