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:
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}`;
},