iuna

iuna

iuna - experimental devnet protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit d4eba57324d3e86a16b24b6633399eb2720e1d33
parent 94e3cf4c154d36c06a1ef87743b2d1f3ef8d5cde
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 14 Aug 2026 12:17:39 +0200

Add development mode metrics UI

Diffstat:
Msrc/adapters/http/api.rs | 63++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/adapters/http/index_html.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/adapters/http/metrics.rs | 11++++++++---
Msrc/adapters/http/tests.rs | 7+++++--
Msrc/adapters/http/types.rs | 17+++++++++++++++++
Msrc/adapters/ui_data_store.rs | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwww/assets/iuna-ui.js | 40++++++++++++++++++++++++++++++++++++----
7 files changed, 290 insertions(+), 40 deletions(-)

diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -14,6 +14,7 @@ use crate::{ domain::{BlindedTransaction, OutPoint, Transaction, TxOutput}, }; +use super::types::{LeaderboardEntry, MetricsLeaderboards}; use super::{ BlocksQuery, ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse, NetworkHealthLocalState, NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction, @@ -503,18 +504,10 @@ pub(super) async fn api_metrics( ) -> Json<MetricsResponse> { let enabled = state.ui_config.lock().await.keep_track_of_metrics; if !enabled { - return Json(MetricsResponse { - enabled, - latest: None, - charts: Vec::new(), - }); + return Json(empty_metrics_response(enabled)); } if ensure_ui_data_current(&state).await.is_err() { - return Json(MetricsResponse { - enabled, - latest: None, - charts: Vec::new(), - }); + return Json(empty_metrics_response(enabled)); } let store = state.ui_data_store.clone(); let rows = tokio::task::spawn_blocking(move || match query.limit { @@ -525,7 +518,55 @@ pub(super) async fn api_metrics( .ok() .and_then(Result::ok) .unwrap_or_default(); - Json(metrics_response(enabled, rows)) + let store = state.ui_data_store.clone(); + let leaderboards = tokio::task::spawn_blocking(move || store.load_leaderboards(10)) + .await + .ok() + .and_then(Result::ok) + .map(metrics_leaderboards) + .unwrap_or_default(); + Json(metrics_response(enabled, rows, leaderboards)) +} + +fn empty_metrics_response(enabled: bool) -> MetricsResponse { + MetricsResponse { + enabled, + latest: None, + charts: Vec::new(), + leaderboards: MetricsLeaderboards::default(), + } +} + +fn metrics_leaderboards( + leaderboards: crate::adapters::ui_data_store::UiLeaderboards, +) -> MetricsLeaderboards { + MetricsLeaderboards { + balances: leaderboards + .balances + .into_iter() + .map(metrics_leaderboard_entry) + .collect(), + miners: leaderboards + .miners + .into_iter() + .map(metrics_leaderboard_entry) + .collect(), + burners: leaderboards + .burners + .into_iter() + .map(metrics_leaderboard_entry) + .collect(), + } +} + +fn metrics_leaderboard_entry( + entry: crate::adapters::ui_data_store::UiLeaderboardEntry, +) -> LeaderboardEntry { + LeaderboardEntry { + address: entry.address, + amount: entry.amount, + count: entry.count, + } } pub(super) async fn api_network_health( diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -150,6 +150,8 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .metrics-head h2 { margin: 0; } .metrics-range { flex: 0 0 auto; } .metrics-range button { padding: 5px 9px; font-size: 12px; white-space: nowrap; } + .metrics-subhead { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; margin-top: 8px; } + .metrics-subhead h2 { margin: 0; } .metrics-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; } .metrics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 430px), 1fr)); gap: 12px; } .metric-chart-card { display: grid; gap: 10px; min-width: 0; border: 1px solid #2a3035; border-radius: 8px; padding: 12px; background: #181b1f; } @@ -170,6 +172,17 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .metric-chart-hover-point { position: absolute; width: 8px; height: 8px; border-radius: 50%; background: #d5f55f; pointer-events: none; transform: translate(-50%, -50%); box-shadow: 0 0 0 4px rgba(213, 245, 95, .18); } .metric-chart-tooltip { position: absolute; z-index: 1; max-width: min(180px, 80%); border: 1px solid #566d25; border-radius: 6px; padding: 5px 7px; background: #202615; color: #e8edf0; font-size: 11px; font-weight: 850; font-variant-numeric: tabular-nums; line-height: 1.25; pointer-events: none; box-shadow: 0 8px 20px rgba(0, 0, 0, .28); white-space: nowrap; } .metrics-empty { border: 1px dashed #3a4248; border-radius: 8px; padding: 14px; color: #8d989f; background: #111316; } + .leaderboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 12px; } + .leaderboard-card { min-width: 0; border: 1px solid #2a3035; border-radius: 8px; padding: 12px; background: #181b1f; } + .leaderboard-card h3 { margin: 0 0 10px; } + .leaderboard-list { display: grid; gap: 8px; } + .leaderboard-row { display: grid; grid-template-columns: 54px minmax(0, 1fr) auto; gap: 10px; align-items: center; border: 1px solid #2f363c; border-radius: 8px; padding: 9px; background: #111316; } + .leaderboard-rank { display: grid; place-items: center; min-height: 30px; border: 1px solid #3a4248; border-radius: 999px; color: #a8b2b8; font-size: 11px; font-weight: 900; } + .leaderboard-rank.medal-1 { border-color: #ffd070; background: #2d2513; color: #ffd070; } + .leaderboard-rank.medal-2 { border-color: #c7d0d5; background: #20252a; color: #e8edf0; } + .leaderboard-rank.medal-3 { border-color: #c69262; background: #2a1f17; color: #ffc18a; } + .leaderboard-main { min-width: 0; display: grid; gap: 3px; } + .leaderboard-amount { color: #d5f55f; font-size: 13px; font-weight: 900; font-variant-numeric: tabular-nums; white-space: nowrap; } .wallet-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); gap: 12px; align-items: start; } .wallet-actions { display: grid; gap: 12px; } .advanced-toggle { flex-basis: 100%; width: max-content; align-self: flex-start; border-color: #3a4248; padding: 4px 7px; background: #202328; color: #9fa8ad; font-size: 12px; } @@ -440,7 +453,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=102"></script> + <script defer src="/assets/iuna-ui.js?v=103"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -456,7 +469,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M7 15l4-4 3 3 5-7"></path></svg> <span>Mining</span> </button> - <button class="nav-button" x-show="advancedMode()" :class="{ active: tab === 'p2p' }" @click="setTab('p2p')" type="button" title="P2P" aria-label="P2P"> + <button class="nav-button" :class="{ active: tab === 'p2p' }" @click="setTab('p2p')" type="button" title="P2P" aria-label="P2P"> <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="6" r="3"></circle><circle cx="18" cy="18" r="3"></circle><path d="M8.5 10.5 15.5 7.5"></path><path d="M8.5 13.5 15.5 16.5"></path></svg> <span>P2P</span> </button> @@ -464,7 +477,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <svg class="chain-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="1.5" y="9" width="5.5" height="5.5"></rect><rect x="9.25" y="9" width="5.5" height="5.5"></rect><rect x="17" y="9" width="5.5" height="5.5"></rect></svg> <span>Chain</span> </button> - <button class="nav-button" x-show="config.keep_track_of_metrics" :class="{ active: tab === 'metrics' }" @click="setTab('metrics')" type="button" title="Metrics" aria-label="Metrics"> + <button class="nav-button" x-show="developmentMode()" :class="{ active: tab === 'metrics' }" @click="setTab('metrics')" type="button" title="Metrics" aria-label="Metrics"> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M7 15l3-4 3 2 4-7"></path><path d="M7 17h10"></path></svg> <span>Metrics</span> </button> @@ -485,7 +498,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <h1 x-text="pageTitle()">iuna</h1> <div class="basic-status-row" x-show="basicMode()"> <span class="basic-status" :class="networkHealthClass()" x-text="basicNetworkStatusLabel()"></span> - <button class="basic-status-detail" type="button" x-show="basicNetworkNeedsAttention()" @click="setUiMode('advanced'); setTab('p2p')">Details</button> + <button class="basic-status-detail" type="button" x-show="basicNetworkNeedsAttention()" @click="setTab('p2p')">Details</button> </div> </div> <div class="header-actions"> @@ -661,7 +674,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="mine-stat-value" x-text="localMiningMempoolLabel()"></div> </div> </div> - <div class="mining-event-log" aria-label="Mining event log"> + <div class="mining-event-log" x-show="developmentMode()" aria-label="Mining event log"> <div class="mining-event-log-head"><span>Event log</span><span x-text="`${miningEventLog().length} lines`"></span></div> <template x-if="miningEventLog().length === 0"> <div class="mining-event-empty skeleton-card" aria-hidden="true"><div class="skeleton-line"></div></div> @@ -776,7 +789,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <button class="primary" type="submit">Add</button> </form> </div> - <div class="network-health"> + <div class="network-health" x-show="developmentMode()"> <div class="network-health-state" :class="networkHealthClass()"> <div class="network-health-label">Network Health</div> <div class="network-health-value" x-text="networkHealth.state || '-'"></div> @@ -796,7 +809,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="peer-summary-item"><div class="peer-summary-label">Clock Warnings</div><div class="peer-summary-value" x-text="networkHealth.bad_clock_peers ?? '-'"></div></div> </div> </div> - <div class="peer-summary"> + <div class="peer-summary" x-show="developmentMode()"> <div class="peer-summary-item"><div class="peer-summary-label">Outbound</div><div class="peer-summary-value" x-text="outboundPeers().length"></div></div> <div class="peer-summary-item"><div class="peer-summary-label">Inbound</div><div class="peer-summary-value" x-text="inboundPeers().length"></div></div> <div class="peer-summary-item"><div class="peer-summary-label">Healthy</div><div class="peer-summary-value" x-text="healthyPeers().length"></div></div> @@ -805,7 +818,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="table-wrap"> <table> - <thead><tr><th>Status</th><th>Address</th><th>Direction</th><th>Last Contact</th><th>Clock</th><th>Ban</th><th>Score</th><th>Height</th><th>Delta</th><th>Tip</th><th>Sent</th><th>Received</th><th>Last Error</th><th>Actions</th></tr></thead> + <thead><tr><th>Status</th><th>Address</th><th>Direction</th><th>Last Contact</th><th x-show="developmentMode()">Clock</th><th x-show="developmentMode()">Ban</th><th x-show="developmentMode()">Score</th><th>Height</th><th x-show="developmentMode()">Delta</th><th x-show="developmentMode()">Tip</th><th x-show="developmentMode()">Sent</th><th x-show="developmentMode()">Received</th><th x-show="developmentMode()">Last Error</th><th>Actions</th></tr></thead> <tbody> <template x-for="peer in peers" :key="peer.address"> <tr> @@ -813,15 +826,15 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <td><code x-text="peer.address"></code></td> <td x-text="peer.direction"></td> <td x-text="peerLastContactLabel(peer)"></td> - <td x-text="peerClockLabel(peer)"></td> - <td x-text="peerBanLabel(peer)"></td> - <td x-text="peer.misbehavior_score ?? 0"></td> + <td x-show="developmentMode()" x-text="peerClockLabel(peer)"></td> + <td x-show="developmentMode()" x-text="peerBanLabel(peer)"></td> + <td x-show="developmentMode()" x-text="peer.misbehavior_score ?? 0"></td> <td x-text="peer.last_known_height ?? '-'"></td> - <td x-text="peerHeightDelta(peer)"></td> - <td><code x-text="short(peer.last_known_tip_hash)"></code></td> - <td x-text="peer.messages_sent"></td> - <td x-text="peer.messages_received"></td> - <td x-text="peer.last_error || ''"></td> + <td x-show="developmentMode()" x-text="peerHeightDelta(peer)"></td> + <td x-show="developmentMode()"><code x-text="short(peer.last_known_tip_hash)"></code></td> + <td x-show="developmentMode()" x-text="peer.messages_sent"></td> + <td x-show="developmentMode()" x-text="peer.messages_received"></td> + <td x-show="developmentMode()" x-text="peer.last_error || ''"></td> <td><div class="peer-actions"><button class="peer-remove" type="button" x-show="canRemovePeer(peer)" @click="removePeer(peer)">Remove</button><span class="muted" x-show="!canRemovePeer(peer)">Observed</span></div></td> </tr> </template> @@ -837,7 +850,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> </table> </div> </div> - <div class="panel"> + <div class="panel" x-show="developmentMode()"> <h2>Metrics</h2> <div class="grid"> <div class="metric"><div class="label">Inbound Sessions</div><div class="value" x-text="p2pMetrics.inbound_sessions_started ?? 0"></div></div> @@ -1099,6 +1112,29 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> </article> </template> </div> + <div class="metrics-subhead"> + <h2>Leaderboards</h2> + </div> + <div class="leaderboard-grid"> + <template x-for="board in [{ key: 'balances', title: 'Top 10 Balance' }, { key: 'miners', title: 'Top 10 Miners' }, { key: 'burners', title: 'Top 10 Burners' }]" :key="board.key"> + <article class="leaderboard-card"> + <h3 x-text="board.title"></h3> + <div class="leaderboard-list"> + <template x-for="(row, index) in leaderboardRows(board.key)" :key="`${board.key}-${row.address}`"> + <div class="leaderboard-row"> + <div class="leaderboard-rank" :class="leaderboardRankClass(index)" x-text="leaderboardRankLabel(index)"></div> + <div class="leaderboard-main"> + <code class="tx-value hash" x-text="shortAddressLabel(row.address)"></code> + <div class="muted" x-text="leaderboardCountLabel(board.key, row)"></div> + </div> + <div class="leaderboard-amount" x-text="leaderboardAmountLabel(row)"></div> + </div> + </template> + <div class="metrics-empty" x-show="leaderboardRows(board.key).length === 0">No entries</div> + </div> + </article> + </template> + </div> </div> </section> <section x-show="tab === 'settings'"> @@ -1123,13 +1159,13 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="panel"> <div class="settings-mode-row"> <div class="settings-mode-copy"> - <div class="settings-mode-title">Keep track of metrics</div> - <div class="muted" x-text="keepTrackOfMetrics ? 'Metrics are stored per block.' : 'Metrics storage is off.'"></div> + <div class="settings-mode-title">Development mode</div> + <div class="muted" x-text="developmentMode() ? 'Detailed P2P data and the metrics screen are available.' : 'P2P stays focused on a simple peer list.'"></div> </div> <label class="toggle-switch" :class="{ active: keepTrackOfMetrics }"> <input type="checkbox" :checked="keepTrackOfMetrics" @change="setKeepTrackOfMetrics($event.target.checked)"> <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> - <span class="toggle-text" x-text="keepTrackOfMetrics ? 'On' : 'Off'"></span> + <span class="toggle-text" x-text="developmentMode() ? 'On' : 'Off'"></span> </label> </div> </div> diff --git a/src/adapters/http/metrics.rs b/src/adapters/http/metrics.rs @@ -7,8 +7,8 @@ use crate::{ use super::{ PEER_STALE_AFTER_MS, now_ms, types::{ - MempoolCounts, MetricsChart, MetricsPoint, MetricsResponse, MetricsValueKind, - NetworkHealthLocalState, NetworkHealthResponse, + MempoolCounts, MetricsChart, MetricsLeaderboards, MetricsPoint, MetricsResponse, + MetricsValueKind, NetworkHealthLocalState, NetworkHealthResponse, }, }; @@ -20,11 +20,16 @@ pub(super) fn network_health( network_health_at(local, peers, mempool, now_ms()) } -pub(super) fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse { +pub(super) fn metrics_response( + enabled: bool, + rows: Vec<BlockMetricRow>, + leaderboards: MetricsLeaderboards, +) -> MetricsResponse { let latest = rows.last().cloned(); MetricsResponse { enabled, latest, + leaderboards, charts: vec![ metrics_chart( "block-time", diff --git a/src/adapters/http/tests.rs b/src/adapters/http/tests.rs @@ -1472,6 +1472,7 @@ fn metrics_response_skips_bootstrap_points_for_block_time_and_vdf_rounds() { metric_row(2, Some(600_000), 120), metric_row(3, Some(610_000), 130), ], + super::types::MetricsLeaderboards::default(), ); let block_time = response @@ -1519,7 +1520,7 @@ fn metrics_response_skips_bootstrap_points_for_block_time_and_vdf_rounds() { #[test] fn metrics_screen_includes_block_range_filter() { - assert!(super::INDEX_HTML.contains("iuna-ui.js?v=102")); + assert!(super::INDEX_HTML.contains("iuna-ui.js?v=103")); assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\"")); assert!(super::INDEX_HTML.contains("setMetricsRange(100)")); assert!(super::INDEX_HTML.contains("setMetricsRange(1000)")); @@ -1835,7 +1836,9 @@ fn polling_refreshes_paged_datasets_without_visible_loaders() { assert!(app_js.contains("options.force === true")); assert!(app_js.contains("this.setTab(this.tabFromHash());")); assert!(app_js.contains("const shouldLoadBlocks = tab === \"chain\" || tab === \"mining\";")); - assert!(app_js.contains("const shouldLoadP2pMetrics = tab === \"p2p\";")); + assert!( + app_js.contains("const shouldLoadP2pMetrics = tab === \"p2p\" && this.developmentMode();") + ); assert!(app_js.contains("const shouldLoadMetrics = tab === \"metrics\";")); assert!( app_js diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -244,6 +244,23 @@ pub(super) struct MetricsResponse { pub(super) enabled: bool, pub(super) latest: Option<BlockMetricRow>, pub(super) charts: Vec<MetricsChart>, + pub(super) leaderboards: MetricsLeaderboards, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct MetricsLeaderboards { + pub(super) balances: Vec<LeaderboardEntry>, + pub(super) miners: Vec<LeaderboardEntry>, + pub(super) burners: Vec<LeaderboardEntry>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct LeaderboardEntry { + pub(super) address: String, + pub(super) amount: Amount, + pub(super) count: u64, } #[derive(Clone, Debug, PartialEq, Serialize)] diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs @@ -138,6 +138,13 @@ pub struct BlockMetricRow { } #[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiLeaderboardEntry { + pub address: String, + pub amount: Amount, + pub count: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub struct WalletTransactionProjection { pub sort_key: u64, pub kind: String, @@ -383,6 +390,16 @@ LIMIT ?1 }) } + pub fn load_leaderboards(&self, limit: usize) -> Result<UiLeaderboards> { + self.with_connection(|connection| { + Ok(UiLeaderboards { + balances: load_balance_leaderboard(connection, limit)?, + miners: load_transaction_leaderboard(connection, "mine", limit)?, + burners: load_transaction_leaderboard(connection, "burn", limit)?, + }) + }) + } + pub fn load_wallet_utxos(&self, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> { self.with_connection(|connection| load_wallet_utxos(connection, address)) } @@ -439,6 +456,105 @@ PRAGMA synchronous = NORMAL; } } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UiLeaderboards { + pub balances: Vec<UiLeaderboardEntry>, + pub miners: Vec<UiLeaderboardEntry>, + pub burners: Vec<UiLeaderboardEntry>, +} + +fn load_balance_leaderboard( + connection: &Connection, + limit: usize, +) -> Result<Vec<UiLeaderboardEntry>> { + let mut statement = connection + .prepare( + r#" +SELECT address, SUM(amount) AS total_amount, COUNT(*) AS output_count +FROM ui_utxos +GROUP BY address +HAVING total_amount > 0 +ORDER BY total_amount DESC, address ASC +LIMIT ?1 +"#, + ) + .context("failed to prepare balance leaderboard query")?; + let rows = statement + .query_map([limit as u64], |row| { + Ok(UiLeaderboardEntry { + address: row.get(0)?, + amount: row.get(1)?, + count: row.get(2)?, + }) + }) + .context("failed to load balance leaderboard")?; + rows.collect::<std::result::Result<Vec<_>, _>>() + .context("failed to read balance leaderboard rows") +} + +fn load_transaction_leaderboard( + connection: &Connection, + kind: &str, + limit: usize, +) -> Result<Vec<UiLeaderboardEntry>> { + let mut statement = connection + .prepare( + r#" +SELECT address, transaction_json +FROM ui_wallet_transactions +WHERE kind = ?1 +"#, + ) + .with_context(|| format!("failed to prepare {kind} leaderboard query"))?; + let rows = statement + .query_map([kind], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?)) + }) + .with_context(|| format!("failed to load {kind} leaderboard"))?; + let mut entries = BTreeMap::<String, UiLeaderboardEntry>::new(); + for row in rows { + let (address, transaction_json) = + row.with_context(|| format!("failed to read {kind} leaderboard row"))?; + let transaction = + serde_json::from_slice::<Transaction>(&transaction_json).with_context(|| { + format!( + "failed to parse {kind} leaderboard transaction JSON with {} bytes", + transaction_json.len() + ) + })?; + let amount = match transaction { + Transaction::Mine { .. } => MINE_REWARD, + Transaction::Burn { amount, .. } => amount, + Transaction::Transfer { .. } => 0, + }; + let entry = entries + .entry(address.clone()) + .or_insert_with(|| UiLeaderboardEntry { + address, + amount: 0, + count: 0, + }); + entry.amount = entry + .amount + .checked_add(amount) + .with_context(|| format!("{kind} leaderboard amount overflow"))?; + entry.count = entry + .count + .checked_add(1) + .with_context(|| format!("{kind} leaderboard count overflow"))?; + } + let mut entries = entries.into_values().collect::<Vec<_>>(); + entries.sort_by(|left, right| { + right + .amount + .cmp(&left.amount) + .then_with(|| right.count.cmp(&left.count)) + .then_with(|| left.address.cmp(&right.address)) + }); + entries.truncate(limit); + Ok(entries) +} + fn replace_metrics( transaction: &rusqlite::Transaction<'_>, metrics: &[BlockMetricRow], diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -188,13 +188,17 @@ window.iunaApp = function iunaApp() { allowedTabs() { const tabs = this.advancedMode() ? ["wallet", "mining", "p2p", "chain", "settings"] - : ["wallet", "chain", "settings"]; - if (this.config.keep_track_of_metrics) { + : ["wallet", "p2p", "chain", "settings"]; + if (this.developmentMode()) { tabs.splice(tabs.indexOf("chain") + 1, 0, "metrics"); } return tabs; }, + developmentMode() { + return this.keepTrackOfMetrics === true || this.config.keep_track_of_metrics === true; + }, + basicMode() { return this.uiMode !== "advanced"; }, @@ -636,7 +640,7 @@ window.iunaApp = function iunaApp() { const addressBookVersion = this.addressBookVersion; const tab = this.tab; const shouldLoadBlocks = tab === "chain" || tab === "mining"; - const shouldLoadP2pMetrics = tab === "p2p"; + const shouldLoadP2pMetrics = tab === "p2p" && this.developmentMode(); const shouldLoadMetrics = tab === "metrics"; if (shouldLoadMetrics) { await this.refreshMetrics(options); @@ -1521,7 +1525,7 @@ window.iunaApp = function iunaApp() { await this.postForm( "/api/settings/metrics", { enabled }, - enabled ? "Metrics tracking turned on" : "Metrics tracking turned off" + enabled ? "Development mode turned on" : "Development mode turned off" ); await this.refreshConfig(); if (!enabled && this.tab === "metrics") { @@ -1828,6 +1832,34 @@ window.iunaApp = function iunaApp() { return this.blockchainMetrics?.latest || {}; }, + metricsLeaderboards() { + return this.blockchainMetrics?.leaderboards || {}; + }, + + leaderboardRows(kind) { + const rows = this.metricsLeaderboards()?.[kind]; + return Array.isArray(rows) ? rows : []; + }, + + leaderboardRankLabel(index) { + return ["Gold", "Silver", "Bronze"][index] || `#${index + 1}`; + }, + + leaderboardRankClass(index) { + return index < 3 ? `medal-${index + 1}` : ""; + }, + + leaderboardAmountLabel(row) { + return `IUNA ${this.amountLabel(row?.amount || 0)}`; + }, + + leaderboardCountLabel(kind, row) { + const count = Number(row?.count || 0); + if (kind === "miners") return `${count} mine${count === 1 ? "" : "s"}`; + if (kind === "burners") return `${count} burn${count === 1 ? "" : "s"}`; + return `${count} UTXO${count === 1 ? "" : "s"}`; + }, + metricsPath(range = this.metricsRange) { return range === "all" ? "/api/metrics" : `/api/metrics?limit=${range}`; },