From afad8601cb62078617ef666dd59efa18913e2283 Mon Sep 17 00:00:00 2001 From: Miles 'wedtm' Smith Date: Sat, 27 Dec 2025 07:27:30 -0500 Subject: [PATCH 1/3] Add leader map view with geolocation --- README.md | 6 + leader-stream/Cargo.toml | 3 + leader-stream/src/config.rs | 23 +- leader-stream/src/docs.html | 32 ++ leader-stream/src/geo.rs | 222 +++++++++++ leader-stream/src/handlers.rs | 141 +++++-- leader-stream/src/index.html | 2 + leader-stream/src/lib.rs | 2 +- leader-stream/src/main.rs | 20 +- leader-stream/src/map.html | 473 ++++++++++++++++++++++++ leader-stream/src/models.rs | 26 +- leader-stream/src/server.rs | 15 +- leader-stream/src/state.rs | 15 +- leader-stream/src/template.rs | 7 + leader-stream/src/tests.rs | 108 +++++- leader-stream/tests/integration_http.rs | 9 + 16 files changed, 1038 insertions(+), 66 deletions(-) create mode 100644 leader-stream/src/geo.rs create mode 100644 leader-stream/src/map.html diff --git a/README.md b/README.md index aab45dd..7c2a673 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,11 @@ docker run -p 3000:3000 --env-file .env leader-stream | `TRACK_LOOKAHEAD` | Slots to prefetch per tracked validator | 5000 | | `STATIC_DIR` | Override static dir | `/leader-stream/public` | | `NEXT_PUBLIC_LEADER_STREAM_URL` | Override SSE path injected into HTML | `/api/leader-stream` | +| `MAXMIND_DB_PATH` | Path to the MaxMind MMDB file to use for geolocation | `./GeoLite2-City.mmdb` | +| `MAXMIND_LICENSE_KEY` | Optional MaxMind license key for downloading GeoLite/GeoIP2 | none | +| `MAXMIND_DB_DOWNLOAD_URL` | Override URL for downloading the MMDB (expects raw file or tar.gz) | none | +| `MAXMIND_FALLBACK_URL` | Fallback URL for a free/test MaxMind database when no key is present | MaxMind test DB | +| `MAXMIND_EDITION_ID` | Edition ID when downloading via license key | `GeoLite2-City` | See `.env.example` and `k8s/secret.env.example` for templates. @@ -57,6 +62,7 @@ Static docs at `/docs.html` (source: `leader-stream/public/docs.html`). Key endp - `GET /api/next-leaders?limit=1000` - `GET /api/current-slot` - `GET /api/leader-stream?track=` (SSE) +- `GET /map` globe view of upcoming leaders with geolocation (uses `/api/leader-path`) ## Deployment (Kubernetes) `k8s/` uses Kustomize. Replace image `ghcr.io/trustless-engineering/leader-stream:${GIT_SHA}` and supply your own overlays/secrets: diff --git a/leader-stream/Cargo.toml b/leader-stream/Cargo.toml index 41e4d33..1a94d93 100644 --- a/leader-stream/Cargo.toml +++ b/leader-stream/Cargo.toml @@ -14,7 +14,9 @@ anyhow = "1" async-stream = "0.3" axum = { version = "0.7", features = ["macros"] } bytes = "1" +flate2 = "1" futures-util = "0.3" +maxminddb = "0.24" reqwest = { version = "0.11", features = ["json", "rustls-tls", "stream"] } rustls = { version = "0.23", default-features = false, features = ["ring"] } serde_json = "1" @@ -23,6 +25,7 @@ tokio-tungstenite = { version = "0.23", features = ["rustls-tls-native-roots"] } tower-http = { version = "0.5", features = ["fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tar = "0.4" url = "2" dotenvy = "0.15" diff --git a/leader-stream/src/config.rs b/leader-stream/src/config.rs index 9161aee..0035d90 100644 --- a/leader-stream/src/config.rs +++ b/leader-stream/src/config.rs @@ -22,6 +22,11 @@ pub(crate) struct Config { pub(crate) ws_ping_interval: Duration, pub(crate) leader_lookahead: usize, pub(crate) track_lookahead: usize, + pub(crate) maxmind_db_path: String, + pub(crate) maxmind_license_key: Option, + pub(crate) maxmind_edition_id: String, + pub(crate) maxmind_db_download_url: Option, + pub(crate) maxmind_fallback_url: Option, } impl Config { @@ -32,10 +37,7 @@ impl Config { .clone() .unwrap_or_else(|| DEFAULT_RPC_URL.to_string()); if using_default_rpc { - warn!( - "SOLANA_RPC_URL not set; defaulting to {}", - DEFAULT_RPC_URL - ); + warn!("SOLANA_RPC_URL not set; defaulting to {}", DEFAULT_RPC_URL); } let rpc_x_token = read_env_first(&["SOLANA_RPC_X_TOKEN"]); let ws_override = read_env_first(&["SOLANA_WSS_URL", "SOLANA_WS_URL"]); @@ -94,6 +96,14 @@ impl Config { .and_then(|value| value.parse::().ok()) .unwrap_or(DEFAULT_TRACK_LOOKAHEAD); + let maxmind_db_path = + env::var("MAXMIND_DB_PATH").unwrap_or_else(|_| "./GeoLite2-City.mmdb".to_string()); + let maxmind_license_key = read_env_first(&["MAXMIND_LICENSE_KEY", "GEOIP_LICENSE_KEY"]); + let maxmind_edition_id = + env::var("MAXMIND_EDITION_ID").unwrap_or_else(|_| "GeoLite2-City".to_string()); + let maxmind_db_download_url = read_env_first(&["MAXMIND_DB_DOWNLOAD_URL"]); + let maxmind_fallback_url = read_env_first(&["MAXMIND_FALLBACK_URL"]); + Ok(Self { rpc_url, rpc_x_token, @@ -106,6 +116,11 @@ impl Config { ws_ping_interval, leader_lookahead, track_lookahead, + maxmind_db_path, + maxmind_license_key, + maxmind_edition_id, + maxmind_db_download_url, + maxmind_fallback_url, }) } } diff --git a/leader-stream/src/docs.html b/leader-stream/src/docs.html index cbdb9f9..6382cc6 100644 --- a/leader-stream/src/docs.html +++ b/leader-stream/src/docs.html @@ -46,6 +46,7 @@

/api/next-leaders /api/current-slot /api/leader-stream + /api/leader-path @@ -158,6 +159,37 @@

GET /api/leader-stream

+ +
+
+
+

GET /api/leader-path

+

Returns upcoming leaders plus MaxMind geolocation data for the map view.

+
+
+
+
+ Query params +
    +
  • limit Optional integer. Defaults to 1000, clamps between 1 and 5000. Invalid values fall back to the default.
  • +
+
+
+ Response fields +
    +
  • currentSlot Latest slot used as the starting point.
  • +
  • limit The resolved limit after clamping.
  • +
  • slotMs Estimated milliseconds per slot.
  • +
  • path Array of rows with slot, leader, ip, port, and geolocation fields latitude, longitude, city, country.
  • +
  • ts Server timestamp (ms since epoch).
  • +
+
+
+ Example +
+
+
+
+ + + +
+ +
+
+
+ + Solana Mainnet - Live Leader Path +
+

+ Solana TPU + Leader Map +

+
Ready.
+
+ +
+
+
+
+

Leader path

+

+ Consecutive leaders are connected in order. Only TPUs with valid + geolocation appear on the globe. +

+
+
+
+
+
+ Limit + +
+
+ Last refresh + Never +
+
+
+ +
+
+
+ Leaders + - +
+
+ With location + - +
+
+ Current slot + - +
+
+
    +
  1. Loading path…
  2. +
+
+
+
+
+ + + diff --git a/leader-stream/src/models.rs b/leader-stream/src/models.rs index d7e4ebf..c5a2976 100644 --- a/leader-stream/src/models.rs +++ b/leader-stream/src/models.rs @@ -35,6 +35,29 @@ pub(crate) struct LeaderRowPayload { pub(crate) port: Option, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LeaderLocationPayload { + pub(crate) slot: u64, + pub(crate) leader: String, + pub(crate) ip: Option, + pub(crate) port: Option, + pub(crate) latitude: Option, + pub(crate) longitude: Option, + pub(crate) city: Option, + pub(crate) country: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LeaderPathPayload { + pub(crate) current_slot: u64, + pub(crate) limit: usize, + pub(crate) slot_ms: u64, + pub(crate) ts: u64, + pub(crate) path: Vec, +} + #[derive(Clone)] pub(crate) struct NodeInfo { pub(crate) tpu: Option, @@ -90,8 +113,7 @@ pub(crate) struct TrackSchedule { impl TrackSchedule { pub(crate) fn covers_slot(&self, slot: u64) -> bool { - slot >= self.epoch_start - && slot < self.epoch_start.saturating_add(self.slots_in_epoch) + slot >= self.epoch_start && slot < self.epoch_start.saturating_add(self.slots_in_epoch) } pub(crate) fn slots_until(&self, slot: u64) -> Option { diff --git a/leader-stream/src/server.rs b/leader-stream/src/server.rs index 2a789ce..c43772a 100644 --- a/leader-stream/src/server.rs +++ b/leader-stream/src/server.rs @@ -13,8 +13,8 @@ use tower_http::services::ServeDir; use tracing::info; use crate::handlers::{ - current_slot_handler, docs_handler, health_handler, index_handler, leader_stream, - next_leaders_handler, options_handler, + current_slot_handler, docs_handler, health_handler, index_handler, leader_path_handler, + leader_stream, map_handler, next_leaders_handler, options_handler, }; use crate::state::AppState; @@ -23,6 +23,8 @@ pub(crate) fn build_router(state: Arc, static_dir: String) -> Router { .route("/", get(index_handler)) .route("/docs", get(docs_handler)) .route("/docs.html", get(docs_handler)) + .route("/map", get(map_handler)) + .route("/map.html", get(map_handler)) .route( "/api/current-slot", get(current_slot_handler).options(options_handler), @@ -31,7 +33,14 @@ pub(crate) fn build_router(state: Arc, static_dir: String) -> Router { "/api/next-leaders", get(next_leaders_handler).options(options_handler), ) - .route("/api/leader-stream", get(leader_stream).options(options_handler)) + .route( + "/api/leader-path", + get(leader_path_handler).options(options_handler), + ) + .route( + "/api/leader-stream", + get(leader_stream).options(options_handler), + ) .route("/health", get(health_handler)) .fallback_service(ServeDir::new(static_dir)) .with_state(state) diff --git a/leader-stream/src/state.rs b/leader-stream/src/state.rs index 3a40311..ac508af 100644 --- a/leader-stream/src/state.rs +++ b/leader-stream/src/state.rs @@ -5,10 +5,11 @@ use std::sync::Arc; use bytes::Bytes; use tokio::sync::{broadcast, RwLock}; -use leader_stream::{render_docs, render_index}; +use leader_stream::{render_docs, render_index, render_map}; use crate::config::Config; use crate::constants::{API_FALLBACK_SLOT_MS, BROADCAST_BUFFER}; +use crate::geo::GeoIpService; use crate::models::{ BasePayload, CachedPayload, CurrentSlotPayload, LeaderCache, NextLeadersPayload, NodesCache, TrackSchedule, @@ -40,6 +41,7 @@ pub(crate) struct AppState { pub(crate) next_leaders_cache: Arc>>>, pub(crate) initial_html: Arc>, pub(crate) docs_html: Bytes, + pub(crate) map_html: Bytes, pub(crate) leader_stream_url: String, pub(crate) cache_bust: String, pub(crate) rpc: RpcClient, @@ -47,14 +49,21 @@ pub(crate) struct AppState { pub(crate) track_subscribers: AtomicUsize, pub(crate) leader_lookahead: AtomicU64, pub(crate) slot_ms_estimate: AtomicU64, + pub(crate) geoip: Option>, } impl AppState { - pub(crate) fn new(config: Config, rpc: RpcClient, leader_stream_url: String) -> Arc { + pub(crate) fn new( + config: Config, + rpc: RpcClient, + leader_stream_url: String, + geoip: Option, + ) -> Arc { let (sender, _) = broadcast::channel(BROADCAST_BUFFER); let cache_bust = now_ms().to_string(); let base_html = render_index(&leader_stream_url, &cache_bust, None); let docs_html = render_docs(&cache_bust); + let map_html = render_map(&cache_bust, &leader_stream_url); Arc::new(Self { sender, latest: Arc::new(RwLock::new(None)), @@ -66,6 +75,7 @@ impl AppState { next_leaders_cache: Arc::new(RwLock::new(HashMap::new())), initial_html: Arc::new(RwLock::new(Bytes::from(base_html))), docs_html: Bytes::from(docs_html), + map_html: Bytes::from(map_html), leader_stream_url, cache_bust, rpc, @@ -73,6 +83,7 @@ impl AppState { track_subscribers: AtomicUsize::new(0), slot_ms_estimate: AtomicU64::new(API_FALLBACK_SLOT_MS), config, + geoip: geoip.map(Arc::new), }) } diff --git a/leader-stream/src/template.rs b/leader-stream/src/template.rs index c5db154..ff85736 100644 --- a/leader-stream/src/template.rs +++ b/leader-stream/src/template.rs @@ -1,5 +1,6 @@ const INDEX_TEMPLATE: &str = include_str!("index.html"); const DOCS_TEMPLATE: &str = include_str!("docs.html"); +const MAP_TEMPLATE: &str = include_str!("map.html"); const LEADER_STREAM_TOKEN: &str = "{{leader_stream_url}}"; const INITIAL_SCRIPT_TOKEN: &str = "{{initial_script}}"; const CACHE_BUST_TOKEN: &str = "{{cache_bust}}"; @@ -27,3 +28,9 @@ pub fn render_index( pub fn render_docs(cache_bust: &str) -> String { DOCS_TEMPLATE.replace(CACHE_BUST_TOKEN, cache_bust) } + +pub fn render_map(cache_bust: &str, leader_stream_url: &str) -> String { + MAP_TEMPLATE + .replace(CACHE_BUST_TOKEN, cache_bust) + .replace(LEADER_STREAM_TOKEN, leader_stream_url) +} diff --git a/leader-stream/src/tests.rs b/leader-stream/src/tests.rs index e687832..f065f0d 100644 --- a/leader-stream/src/tests.rs +++ b/leader-stream/src/tests.rs @@ -8,6 +8,7 @@ use tower::ServiceExt; use crate::config::Config; use crate::constants::DEFAULT_STATIC_DIR; +use crate::geo::{GeoIpService, GeoPoint}; use crate::models::{CachedPayload, CurrentSlotPayload, NodeInfo}; use crate::rpc::RpcClient; use crate::server::build_router; @@ -26,6 +27,11 @@ fn test_config() -> Config { ws_ping_interval: Duration::from_millis(1000), leader_lookahead: 100, track_lookahead: 200, + maxmind_db_path: "./GeoLite2-City.mmdb".to_string(), + maxmind_license_key: None, + maxmind_edition_id: "GeoLite2-City".to_string(), + maxmind_db_download_url: None, + maxmind_fallback_url: None, } } @@ -37,7 +43,7 @@ fn test_state() -> Arc { config.request_timeout, ) .expect("rpc client"); - AppState::new(config, rpc, "/api/leader-stream".to_string()) + AppState::new(config, rpc, "/api/leader-stream".to_string(), None) } fn test_app(state: Arc) -> axum::Router { @@ -48,7 +54,12 @@ fn test_app(state: Arc) -> axum::Router { async fn health_endpoint_returns_ok() { let app = test_app(test_state()); let response = app - .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) .await .expect("health response"); assert_eq!(response.status(), StatusCode::OK); @@ -181,14 +192,12 @@ async fn leader_stream_sets_event_stream_headers() { .await .expect("leader stream response"); assert_eq!(response.status(), StatusCode::OK); - assert!( - response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|value| value.starts_with("text/event-stream")) - .unwrap_or(false) - ); + assert!(response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.starts_with("text/event-stream")) + .unwrap_or(false)); assert_eq!( response .headers() @@ -224,3 +233,82 @@ async fn leader_stream_sets_event_stream_headers() { let text = String::from_utf8_lossy(data.as_ref()); assert!(text.contains("stream-open")); } + +#[tokio::test] +async fn map_endpoint_returns_html() { + let app = test_app(test_state()); + let response = app + .oneshot(Request::builder().uri("/map").body(Body::empty()).unwrap()) + .await + .expect("map response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/html; charset=utf-8") + ); +} + +#[tokio::test] +async fn leader_path_returns_geolocated_entries() { + let config = test_config(); + let rpc = RpcClient::new( + config.rpc_url.clone(), + config.rpc_x_token.clone(), + config.request_timeout, + ) + .expect("rpc client"); + let mut cache_map = std::collections::HashMap::new(); + cache_map.insert( + "1.2.3.4".to_string(), + Some(GeoPoint { + latitude: 10.5, + longitude: -20.25, + city: Some("Test City".to_string()), + country: Some("Testland".to_string()), + }), + ); + let geoip = GeoIpService::from_static(cache_map); + let state = AppState::new(config, rpc, "/api/leader-stream".to_string(), Some(geoip)); + + { + let mut cache = state.leader_cache.write().await; + cache.start_slot = Some(10); + cache.leaders = vec!["leader-geo".to_string()]; + } + { + let mut cache = state.nodes_cache.write().await; + cache.nodes_by_pubkey.insert( + "leader-geo".to_string(), + NodeInfo { + tpu: Some("1.2.3.4:1000".to_string()), + }, + ); + } + + let app = test_app(state); + let response = app + .oneshot( + Request::builder() + .uri("/api/leader-path?limit=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("leader path response"); + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("leader path body") + .to_bytes(); + let value: serde_json::Value = serde_json::from_slice(&body).expect("leader path json"); + assert_eq!(value["limit"], 1); + assert_eq!(value["path"][0]["leader"], "leader-geo"); + assert_eq!(value["path"][0]["latitude"], 10.5); + assert_eq!(value["path"][0]["longitude"], -20.25); + assert_eq!(value["path"][0]["city"], "Test City"); +} diff --git a/leader-stream/tests/integration_http.rs b/leader-stream/tests/integration_http.rs index 06a6275..d839475 100644 --- a/leader-stream/tests/integration_http.rs +++ b/leader-stream/tests/integration_http.rs @@ -24,4 +24,13 @@ async fn http_endpoints_smoke() { assert!(docs.status().is_success()); let body = docs.text().await.expect("docs body"); assert!(body.contains("/api/next-leaders")); + + let map = client + .get(format!("{}/map", server.base_url())) + .send() + .await + .expect("map request"); + assert!(map.status().is_success()); + let body = map.text().await.expect("map body"); + assert!(body.contains("Leader Map")); } From 248eec6004ba5a26dca7c0bdd3041888fefadae0 Mon Sep 17 00:00:00 2001 From: Miles Smith Date: Sat, 27 Dec 2025 10:35:28 -0500 Subject: [PATCH 2/3] maxmind updates --- .gitignore | 4 + k8s/deployment.yaml | 8 + leader-stream/Cargo.lock | 118 +++++++++ leader-stream/src/geo.rs | 73 +++++- leader-stream/src/map.html | 515 ++++++++++++++++++++++++++++++------- 5 files changed, 619 insertions(+), 99 deletions(-) diff --git a/.gitignore b/.gitignore index 870e811..4bb2200 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ k8s/*.env # TLS private keys / origin certs (keep out of git) certs/ + +# Maxmind +GeoIP.conf +leader-stream/*.mmdb diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 6e6cde8..72ac12a 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -17,6 +17,9 @@ spec: # Required only if the GHCR repo is private. imagePullSecrets: - name: ghcr-pull-secret + volumes: + - name: geoip-data + emptyDir: {} containers: - name: leader-stream # Replace in CI/CD (e.g. envsubst) with the current git SHA. @@ -31,9 +34,14 @@ spec: env: - name: PORT value: "3000" + - name: MAXMIND_DB_PATH + value: "/var/lib/leader-stream/geoip/GeoLite2-City.mmdb" # Optional override for Solana RPC # - name: SOLANA_RPC_URL # value: "https://your-solana-rpc" + volumeMounts: + - name: geoip-data + mountPath: /var/lib/leader-stream/geoip readinessProbe: httpGet: path: /health diff --git a/leader-stream/Cargo.lock b/leader-stream/Cargo.lock index 4a4e13b..90f172c 100644 --- a/leader-stream/Cargo.lock +++ b/leader-stream/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -241,6 +247,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -321,12 +336,34 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -765,6 +802,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + [[package]] name = "itoa" version = "1.0.16" @@ -797,16 +843,19 @@ dependencies = [ "axum", "bytes", "dotenvy", + "flate2", "futures-util", "gloo-timers", "http-body-util", "js-sys", + "maxminddb", "portpicker", "reqwest", "rustls 0.23.35", "serde", "serde-wasm-bindgen", "serde_json", + "tar", "tokio", "tokio-tungstenite", "tower", @@ -825,6 +874,17 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "libredox" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -858,6 +918,18 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "maxminddb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c" +dependencies = [ + "ipnetwork", + "log", + "memchr", + "serde", +] + [[package]] name = "memchr" version = "2.7.6" @@ -880,6 +952,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1099,6 +1181,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "redox_syscall" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "regex-automata" version = "0.4.13" @@ -1442,6 +1533,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.11" @@ -1541,6 +1638,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.23.0" @@ -2262,6 +2370,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/leader-stream/src/geo.rs b/leader-stream/src/geo.rs index 82dbfd5..e4f5c1e 100644 --- a/leader-stream/src/geo.rs +++ b/leader-stream/src/geo.rs @@ -3,13 +3,14 @@ use std::fs; use std::io::{Cursor, Read}; use std::net::IpAddr; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; use anyhow::{anyhow, Context, Result}; use flate2::read::GzDecoder; use maxminddb::geoip2::City; -use maxminddb::Reader; +use maxminddb::{MaxMindDBError, Reader}; use reqwest::Client; use tar::Archive; use tokio::sync::RwLock; @@ -29,6 +30,7 @@ pub(crate) struct GeoPoint { pub(crate) struct GeoIpService { reader: Option>>>, cache: Arc>>>, + lookup_error_logged: Arc, } impl GeoIpService { @@ -36,6 +38,7 @@ impl GeoIpService { Self { reader: Some(Arc::new(reader)), cache: Arc::new(RwLock::new(HashMap::new())), + lookup_error_logged: Arc::new(AtomicBool::new(false)), } } @@ -44,6 +47,7 @@ impl GeoIpService { Self { reader: None, cache: Arc::new(RwLock::new(entries)), + lookup_error_logged: Arc::new(AtomicBool::new(false)), } } @@ -75,10 +79,15 @@ impl GeoIpService { } }; - let result = reader - .lookup::(ip_addr) - .ok() - .and_then(|city| extract_point(&city)); + let result = match reader.lookup::(ip_addr) { + Ok(city) => extract_point(&city), + Err(err) => { + if !matches!(err, MaxMindDBError::AddressNotFoundError(_)) { + self.log_lookup_error_once(err); + } + None + } + }; self.cache_write(ip, result.clone()).await; result } @@ -87,15 +96,63 @@ impl GeoIpService { let mut cache = self.cache.write().await; cache.insert(ip.to_string(), value); } + + fn log_lookup_error_once(&self, err: MaxMindDBError) { + if !self.lookup_error_logged.swap(true, Ordering::SeqCst) { + warn!( + ?err, + "MaxMind database lookup failed; geolocation data will be empty" + ); + } + } } pub(crate) async fn load_geoip(config: &Config) -> Result { let path = resolve_database_path(config)?; if !path.exists() { + info!( + "MaxMind database not found at {}; downloading", + path.display() + ); download_database(config, &path).await?; } + match fs::metadata(&path) { + Ok(metadata) => { + let size = metadata.len(); + info!( + "MaxMind database ready path={} size_bytes={}", + path.display(), + size + ); + if size < 1_000_000 { + warn!( + "MaxMind database appears unusually small; likely a test DB and lookups may fail" + ); + } + } + Err(err) => { + warn!( + ?err, + "failed to read MaxMind database metadata at {}", + path.display() + ); + } + }; let reader = Reader::open_readfile(&path) .with_context(|| format!("failed to open MaxMind database at {}", path.display()))?; + info!( + database_type = %reader.metadata.database_type, + build_epoch = reader.metadata.build_epoch, + ip_version = reader.metadata.ip_version, + node_count = reader.metadata.node_count, + "MaxMind database metadata loaded" + ); + if !reader.metadata.database_type.to_lowercase().contains("city") { + warn!( + database_type = %reader.metadata.database_type, + "MaxMind database type does not look like a City database; geolocation fields may be empty" + ); + } Ok(GeoIpService::from_reader(reader)) } @@ -207,12 +264,14 @@ fn extract_point(city: &City) -> Option { .city .as_ref() .and_then(|record| record.names.as_ref()) - .and_then(|names| names.get("en").cloned()); + .and_then(|names| names.get("en")) + .map(|value| value.to_string()); let country_name = city .country .as_ref() .and_then(|record| record.names.as_ref()) - .and_then(|names| names.get("en").cloned()); + .and_then(|names| names.get("en")) + .map(|value| value.to_string()); Some(GeoPoint { latitude, longitude, diff --git a/leader-stream/src/map.html b/leader-stream/src/map.html index fbe11bb..1de1ad4 100644 --- a/leader-stream/src/map.html +++ b/leader-stream/src/map.html @@ -134,6 +134,21 @@ font-feature-settings: "tnum" on, "lnum" on; } + .follow-toggle { + display: flex; + align-items: center; + gap: 8px; + text-transform: none; + letter-spacing: 0; + font-size: 0.95rem; + } + + .follow-toggle input { + width: 18px; + height: 18px; + accent-color: var(--map-accent-strong); + } + @media (max-width: 960px) { .globe-wrap { grid-template-columns: 1fr; @@ -144,7 +159,7 @@ } } - +
-
- Limit - -
Last refresh Never
+
+ Follow leader + +
@@ -240,11 +253,11 @@

Leader path

const metaEl = document.getElementById("meta"); const globeHost = document.getElementById("globe"); const pathList = document.getElementById("path-list"); - const limitSelect = document.getElementById("limit-select"); const lastUpdated = document.getElementById("last-updated"); const leadersCountEl = document.getElementById("leaders-count"); const geoCountEl = document.getElementById("geo-count"); const currentSlotEl = document.getElementById("current-slot"); + const followToggle = document.getElementById("follow-current"); const leaderStreamUrl = document.querySelector('meta[name="leader-stream-url"]')?.content || "/api/leader-stream"; @@ -252,18 +265,30 @@

Leader path

const accent = (rootStyles.getPropertyValue("--map-accent") || "#a855f7").trim(); const accentAlt = (rootStyles.getPropertyValue("--map-accent-alt") || "#22d3ee").trim(); const accentStrong = (rootStyles.getPropertyValue("--map-accent-strong") || "#c084fc").trim(); + const CLUSTER_STEP = 0.6; + const CLUSTER_SAMPLE_LIMIT = 3; + const BASE_POINT_RADIUS = 0.45; + const MAX_POINT_RADIUS = 1.35; + const BASE_ALTITUDE = 0.01; + const MAX_ALTITUDE = 0.09; + const FOLLOW_ALTITUDE = 1.4; + const FLASH_COLOR = accentStrong || accent || "#c084fc"; + const DEFAULT_LIMIT = 5000; let globe; let refreshTimer = null; - let lastRefreshMs = 0; let isLoading = false; - - function getLimit() { - const value = parseInt(limitSelect.value || "250", 10); - if (Number.isNaN(value)) { - return 250; - } - return Math.min(Math.max(value, 50), 1000); - } + let pendingRefresh = false; + let currentSlotValue = null; + let currentLeaderId = null; + let currentClusterKey = null; + let lastFocusedClusterKey = null; + let leaderData = []; + let leaderRuns = []; + let pointData = []; + let leaderSet = new Set(); + let leaderClusterMap = new Map(); + let geolocatedCount = 0; + let flashState = false; function setStatus(message) { if (statusEl) { @@ -272,6 +297,9 @@

Leader path

} function formatLocation(row) { + if (row.locationLabel) { + return row.locationLabel; + } if (row.city && row.country) { return `${row.city}, ${row.country}`; } @@ -281,11 +309,176 @@

Leader path

return row.ip || "Location unavailable"; } + function buildUniqueLeaders(rows) { + const map = new Map(); + rows.forEach((row) => { + if (!row?.leader) { + return; + } + let entry = map.get(row.leader); + if (!entry) { + entry = { + leader: row.leader, + slot: row.slot, + count: 0, + ip: row.ip, + port: row.port, + latitude: null, + longitude: null, + city: null, + country: null, + }; + map.set(row.leader, entry); + } + entry.count += 1; + if (row.slot != null && row.slot < entry.slot) { + entry.slot = row.slot; + } + if (entry.latitude == null && row.latitude != null && row.longitude != null) { + entry.latitude = row.latitude; + entry.longitude = row.longitude; + entry.city = row.city; + entry.country = row.country; + entry.ip = row.ip; + entry.port = row.port; + } + }); + + const leaders = Array.from(map.values()).sort((a, b) => a.slot - b.slot); + const points = leaders.filter( + (row) => row.latitude != null && row.longitude != null, + ); + return { leaders, points }; + } + + function buildClusters(points) { + const clusters = new Map(); + const leaderToCluster = new Map(); + points.forEach((row) => { + if (row.latitude == null || row.longitude == null) { + return; + } + const key = `${Math.round(row.latitude / CLUSTER_STEP)}:${Math.round( + row.longitude / CLUSTER_STEP, + )}`; + let cluster = clusters.get(key); + if (!cluster) { + const locationLabel = + row.city && row.country + ? `${row.city}, ${row.country}` + : row.country || null; + cluster = { + latitude: 0, + longitude: 0, + validators: 0, + count: 0, + slot: row.slot ?? 0, + city: row.city ?? null, + country: row.country ?? null, + locationLabel, + locationKey: `${row.city || ""}|${row.country || ""}`, + clusterKey: key, + leaderSample: [], + }; + clusters.set(key, cluster); + } + leaderToCluster.set(row.leader, key); + cluster.validators += 1; + cluster.count += row.count || 0; + cluster.latitude += row.latitude; + cluster.longitude += row.longitude; + if (row.slot != null && row.slot < cluster.slot) { + cluster.slot = row.slot; + } + if (cluster.locationLabel) { + const rowKey = `${row.city || ""}|${row.country || ""}`; + if (rowKey !== cluster.locationKey) { + cluster.locationLabel = "Multiple locations"; + } + } + if (cluster.leaderSample.length < CLUSTER_SAMPLE_LIMIT) { + cluster.leaderSample.push(row.leader); + } + }); + + const clusterList = Array.from(clusters.values()).map((cluster) => ({ + ...cluster, + latitude: cluster.latitude / cluster.validators, + longitude: cluster.longitude / cluster.validators, + })); + + let maxCount = 1; + let maxValidators = 1; + clusterList.forEach((cluster) => { + if (cluster.count > maxCount) { + maxCount = cluster.count; + } + if (cluster.validators > maxValidators) { + maxValidators = cluster.validators; + } + }); + clusterList.forEach((cluster) => { + const countScale = Math.sqrt(cluster.count / maxCount); + cluster.altitude = + BASE_ALTITUDE + (MAX_ALTITUDE - BASE_ALTITUDE) * countScale; + const validatorScale = Math.sqrt(cluster.validators / maxValidators); + cluster.radius = + BASE_POINT_RADIUS + + (MAX_POINT_RADIUS - BASE_POINT_RADIUS) * validatorScale; + }); + return { clusters: clusterList, leaderToCluster }; + } + + function buildLeaderRuns(rows) { + if (!rows?.length) { + return []; + } + const sorted = rows + .filter((row) => row && row.leader != null && row.slot != null) + .slice() + .sort((a, b) => a.slot - b.slot); + const runs = []; + let current = null; + sorted.forEach((row) => { + const isNext = + current && + row.leader === current.leader && + row.slot === current.endSlot + 1; + if (!current || !isNext) { + current = { + leader: row.leader, + startSlot: row.slot, + endSlot: row.slot, + count: 1, + ip: row.ip ?? null, + port: row.port ?? null, + city: row.city ?? null, + country: row.country ?? null, + }; + runs.push(current); + } else { + current.endSlot = row.slot; + current.count += 1; + } + if (!current.city && row.city) { + current.city = row.city; + } + if (!current.country && row.country) { + current.country = row.country; + } + if (!current.ip && row.ip) { + current.ip = row.ip; + current.port = row.port ?? current.port; + } + }); + return runs; + } + function renderList(path) { pathList.innerHTML = ""; if (!path.length) { const item = document.createElement("li"); - item.textContent = "No geolocated leaders yet."; + item.textContent = "No leaders yet."; pathList.appendChild(item); return; } @@ -294,11 +487,20 @@

Leader path

const item = document.createElement("li"); const slot = document.createElement("span"); slot.className = "slot"; - slot.textContent = `Slot ${row.slot}`; + const slotLabel = + row.startSlot === row.endSlot + ? `Slot ${row.startSlot}` + : `Slots ${row.startSlot}–${row.endSlot}`; + slot.textContent = slotLabel; const location = document.createElement("span"); location.className = "location"; - location.textContent = formatLocation(row); + const until = + currentSlotValue != null + ? Math.max(row.startSlot - currentSlotValue, 0) + : null; + const untilLabel = until != null ? ` · in ${until} slots` : ""; + location.textContent = `${formatLocation(row)} · ${row.count} slots${untilLabel}`; const leader = document.createElement("span"); leader.className = "leader"; @@ -311,32 +513,86 @@

Leader path

}); } - function buildArcs(points) { - const arcs = []; - for (let i = 0; i < points.length - 1; i += 1) { - const start = points[i]; - const end = points[i + 1]; - if ( - start.latitude == null || - start.longitude == null || - end.latitude == null || - end.longitude == null - ) { - continue; + function slotColor(slotsUntil, maxSlotsUntil) { + if (!maxSlotsUntil || maxSlotsUntil <= 0) { + return "rgb(0, 255, 0)"; + } + const clamped = Math.max(0, Math.min(slotsUntil, maxSlotsUntil)); + const t = 1 - clamped / maxSlotsUntil; + const green = Math.round(255 * t); + const blue = Math.round(255 * (1 - t)); + return `rgb(0, ${green}, ${blue})`; + } + + function pointColor(row) { + if (row.isCurrent) { + return flashState ? FLASH_COLOR : row.color || accentAlt; + } + return row.color || accentAlt; + } + + function setGlobeData() { + const globeInstance = ensureGlobe(); + if (!globeInstance) { + setStatus("Globe renderer unavailable."); + return; + } + resizeGlobe(); + globeInstance.pointsData(pointData); + globeInstance.pointLat("latitude"); + globeInstance.pointLng("longitude"); + globeInstance.pointLabel((row) => { + const parts = [ + `${row.validators} validators`, + `Earliest slot ${row.slot}`, + formatLocation(row), + `Slots: ${row.count}`, + ]; + if (row.slotsUntil != null) { + parts.push(`Slots until: ${row.slotsUntil}`); } - const sameCoords = - start.latitude === end.latitude && start.longitude === end.longitude; - if (sameCoords) { - continue; + if (row.leaderSample?.length) { + parts.push(row.leaderSample.join("\n")); + const remaining = row.validators - row.leaderSample.length; + if (remaining > 0) { + parts.push(`+ ${remaining} more`); + } } - arcs.push({ - startLat: start.latitude, - startLng: start.longitude, - endLat: end.latitude, - endLng: end.longitude, - }); + return parts.join("\n"); + }); + globeInstance.arcsData([]); + } + + function updateSlotColors() { + if (currentSlotValue == null || !pointData.length) { + return; + } + let maxSlotsUntil = 0; + pointData.forEach((row) => { + row.slotsUntil = Math.max(row.slot - currentSlotValue, 0); + if (row.slotsUntil > maxSlotsUntil) { + maxSlotsUntil = row.slotsUntil; + } + }); + pointData.forEach((row) => { + row.color = slotColor(row.slotsUntil, maxSlotsUntil); + row.isCurrent = + currentClusterKey != null && row.clusterKey === currentClusterKey; + }); + if (globe) { + globe.pointColor(pointColor); + } + } + + function resizeGlobe() { + if (!globe || !globeHost) { + return; + } + const bounds = globeHost.getBoundingClientRect(); + if (bounds.width && bounds.height) { + globe.width(bounds.width); + globe.height(bounds.height); } - return arcs; } function ensureGlobe() { @@ -354,58 +610,96 @@

Leader path

.showAtmosphere(true) .atmosphereColor(accent) .atmosphereAltitude(0.18) - .pointAltitude(0.008) - .pointRadius(0.9) - .pointColor(() => accentAlt) + .pointRadius((row) => row.radius ?? BASE_POINT_RADIUS) + .pointAltitude((row) => row.altitude ?? BASE_ALTITUDE) + .pointColor(pointColor) .arcColor(() => [accentAlt, accentStrong || accent]) .arcDashLength(0.35) .arcDashGap(0.2) .arcDashAnimateTime(2800) .arcAltitudeAutoScale(true) .arcStroke(0.6); + resizeGlobe(); + window.addEventListener("resize", () => resizeGlobe()); return globe; } - function applyData(data) { - const path = (data?.path || []).filter( - (row) => row.latitude != null && row.longitude != null, + function focusOnCurrentLeader(animate = true) { + if (!followToggle?.checked || !currentClusterKey || !pointData.length) { + return; + } + if (currentClusterKey === lastFocusedClusterKey) { + return; + } + const target = pointData.find( + (row) => row.clusterKey === currentClusterKey, ); - leadersCountEl.textContent = (data?.path || []).length.toLocaleString(); - geoCountEl.textContent = path.length.toLocaleString(); - currentSlotEl.textContent = data?.currentSlot?.toLocaleString() ?? "-"; - lastUpdated.textContent = new Date(data?.ts || Date.now()).toLocaleTimeString(); - - renderList(path); - const arcs = buildArcs(path); + if (!target) { + return; + } const globeInstance = ensureGlobe(); if (!globeInstance) { - setStatus("Globe renderer unavailable."); return; } - globeInstance.pointsData(path); - globeInstance.pointLat("latitude"); - globeInstance.pointLng("longitude"); - globeInstance.pointLabel((row) => `${row.leader}\n${formatLocation(row)}`); - globeInstance.arcsData(arcs); + globeInstance.pointOfView( + { lat: target.latitude, lng: target.longitude, altitude: FOLLOW_ALTITUDE }, + animate ? 900 : 0, + ); + lastFocusedClusterKey = currentClusterKey; + } + + function applyData(data) { + const incoming = Array.isArray(data?.path) ? data.path : []; + if (typeof data?.currentSlot === "number") { + currentSlotValue = data.currentSlot; + } + const unique = buildUniqueLeaders(incoming); + leaderData = unique.leaders; + leaderSet = new Set(unique.leaders.map((row) => row.leader)); + geolocatedCount = unique.points.length; + leaderRuns = buildLeaderRuns(incoming); + const clustered = buildClusters(unique.points); + pointData = clustered.clusters; + leaderClusterMap = clustered.leaderToCluster; + if (currentLeaderId) { + currentClusterKey = leaderClusterMap.get(currentLeaderId) || null; + } + if (currentClusterKey) { + lastFocusedClusterKey = null; + } + + updateSlotColors(); + setGlobeData(); + renderList( + leaderRuns.filter( + (row) => + currentSlotValue == null || row.endSlot >= currentSlotValue, + ), + ); + focusOnCurrentLeader(false); - metaEl.textContent = `${path.length} leaders mapped`; + leadersCountEl.textContent = leaderData.length.toLocaleString(); + geoCountEl.textContent = geolocatedCount.toLocaleString(); + currentSlotEl.textContent = data?.currentSlot?.toLocaleString() ?? "-"; + lastUpdated.textContent = new Date(data?.ts || Date.now()).toLocaleTimeString(); + metaEl.textContent = `${geolocatedCount} leaders mapped across ${pointData.length} clusters`; setStatus("Live."); - lastRefreshMs = Date.now(); } async function loadPath() { - if (isLoading) { - return; - } - isLoading = true; if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; } - const limit = getLimit(); + if (isLoading) { + pendingRefresh = true; + return; + } + isLoading = true; + pendingRefresh = false; setStatus("Loading leader path…"); try { - const response = await fetch(`/api/leader-path?limit=${limit}`, { + const response = await fetch(`/api/leader-path?limit=${DEFAULT_LIMIT}`, { cache: "no-store", }); if (!response.ok) { @@ -420,10 +714,18 @@

Leader path

} finally { refreshTimer = null; isLoading = false; + if (pendingRefresh) { + pendingRefresh = false; + scheduleRefresh(0); + } } } function scheduleRefresh(delay = 800) { + if (isLoading) { + pendingRefresh = true; + return; + } if (refreshTimer) { return; } @@ -437,36 +739,65 @@

Leader path

if (!event?.data) { return; } - if (!lastRefreshMs || Date.now() - lastRefreshMs > 2500) { - scheduleRefresh(600); + let payload = null; + try { + payload = JSON.parse(event.data); + } catch (err) { + return; + } + const current = payload?.currentValidator; + if (!current) { + return; + } + currentLeaderId = current; + currentClusterKey = leaderClusterMap.get(current) || null; + flashState = !flashState; + if (typeof payload?.slot === "number") { + currentSlotValue = payload.slot; + if (currentSlotEl) { + currentSlotEl.textContent = payload.slot.toLocaleString(); + } + updateSlotColors(); + } + focusOnCurrentLeader(true); + if (leaderRuns.length) { + renderList( + leaderRuns.filter( + (row) => + currentSlotValue == null || + row.endSlot >= currentSlotValue, + ), + ); + } + if (!leaderSet.size) { + return; + } + const nextLeader = payload?.nextValidator ?? null; + const hasCurrent = leaderSet.has(current); + const hasNext = !nextLeader || leaderSet.has(nextLeader); + if (!hasCurrent || !hasNext) { + scheduleRefresh(0); } }; source.onerror = () => { setStatus("Live updates unavailable; retrying."); - scheduleRefresh(1200); }; } catch (err) { console.warn("SSE unavailable", err); } } - function syncLimitFromQuery() { - const params = new URLSearchParams(window.location.search); - const param = parseInt(params.get("limit") || "250", 10); - if (!Number.isNaN(param)) { - const clamped = Math.min(Math.max(param, 50), 1000); - limitSelect.value = clamped.toString(); - } + if (followToggle) { + followToggle.addEventListener("change", () => { + if (followToggle.checked) { + lastFocusedClusterKey = null; + focusOnCurrentLeader(false); + } + }); } - limitSelect.addEventListener("change", () => { - scheduleRefresh(0); - }); - - syncLimitFromQuery(); loadPath(); listenForStream(); - setInterval(() => scheduleRefresh(0), 30000); })(); From 7f9dcd647f492d5688a80d705b46920db5f05928 Mon Sep 17 00:00:00 2001 From: Miles Smith Date: Sat, 27 Dec 2025 10:43:49 -0500 Subject: [PATCH 3/3] follow the leader --- k8s/kustomization.yaml | 2 +- leader-stream/src/map.html | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index d667ac7..246aa72 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -7,7 +7,7 @@ resources: - service.yaml - ingress-public.yaml -namespace: leader-stream +namespace: leaderlist generatorOptions: disableNameSuffixHash: true diff --git a/leader-stream/src/map.html b/leader-stream/src/map.html index 1de1ad4..bacc68b 100644 --- a/leader-stream/src/map.html +++ b/leader-stream/src/map.html @@ -211,14 +211,10 @@

Leader path

-
- Last refresh - Never -
Follow leader
@@ -253,7 +249,6 @@

Leader path

const metaEl = document.getElementById("meta"); const globeHost = document.getElementById("globe"); const pathList = document.getElementById("path-list"); - const lastUpdated = document.getElementById("last-updated"); const leadersCountEl = document.getElementById("leaders-count"); const geoCountEl = document.getElementById("geo-count"); const currentSlotEl = document.getElementById("current-slot"); @@ -681,7 +676,6 @@

Leader path

leadersCountEl.textContent = leaderData.length.toLocaleString(); geoCountEl.textContent = geolocatedCount.toLocaleString(); currentSlotEl.textContent = data?.currentSlot?.toLocaleString() ?? "-"; - lastUpdated.textContent = new Date(data?.ts || Date.now()).toLocaleTimeString(); metaEl.textContent = `${geolocatedCount} leaders mapped across ${pointData.length} clusters`; setStatus("Live."); }