mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-24 06:39:58 -07:00
global: snap
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -711,7 +711,9 @@ dependencies = [
|
||||
"brk_types",
|
||||
"byteview",
|
||||
"fjall",
|
||||
"rayon",
|
||||
"rustc-hash",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -308,35 +308,13 @@ impl Indexer {
|
||||
drop(readers);
|
||||
|
||||
let lock = exit.lock();
|
||||
let tasks = self.stores.take_all_pending_ingests(indexes.height)?;
|
||||
self.stores.commit(indexes.height)?;
|
||||
self.vecs.stamped_write(indexes.height)?;
|
||||
let fjall_db = self.stores.db.clone();
|
||||
|
||||
self.vecs.db.run_bg(move |db| {
|
||||
let _lock = lock;
|
||||
|
||||
sleep(Duration::from_secs(5));
|
||||
|
||||
info!("Exporting...");
|
||||
let i = Instant::now();
|
||||
|
||||
if !tasks.is_empty() {
|
||||
let i = Instant::now();
|
||||
for task in tasks {
|
||||
task().map_err(vecdb::RawDBError::other)?;
|
||||
}
|
||||
debug!("Stores committed in {:?}", i.elapsed());
|
||||
|
||||
let i = Instant::now();
|
||||
fjall_db
|
||||
.persist(PersistMode::SyncData)
|
||||
.map_err(RawDBError::other)?;
|
||||
debug!("Stores persisted in {:?}", i.elapsed());
|
||||
}
|
||||
|
||||
db.compact()?;
|
||||
|
||||
info!("Exported in {:?}", i.elapsed());
|
||||
Ok(())
|
||||
});
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ impl Stores {
|
||||
|
||||
let database = match brk_store::open_database(path) {
|
||||
Ok(database) => database,
|
||||
Err(_) if can_retry => {
|
||||
Err(err) if can_retry => {
|
||||
info!("Failed to open stores at {path:?}: {err:?}, deleting and retrying");
|
||||
fs::remove_dir_all(path)?;
|
||||
return Self::forced_import_inner(parent, version, false);
|
||||
}
|
||||
@@ -84,7 +85,7 @@ impl Stores {
|
||||
)
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
let stores = Self {
|
||||
db: database.clone(),
|
||||
|
||||
addr_type_to_addr_hash_to_addr_index: ByAddrType::new_with_index(
|
||||
@@ -113,7 +114,16 @@ impl Stores {
|
||||
Kind::Recent,
|
||||
5,
|
||||
)?,
|
||||
})
|
||||
};
|
||||
|
||||
debug!(
|
||||
"Stores imported: txid_prefix empty={}, blockhash empty={}, keyspace_count={}",
|
||||
stores.txid_prefix_to_tx_index.is_empty()?,
|
||||
stores.blockhash_prefix_to_height.is_empty()?,
|
||||
database.keyspace_count(),
|
||||
);
|
||||
|
||||
Ok(stores)
|
||||
}
|
||||
|
||||
pub fn starting_height(&self) -> Height {
|
||||
@@ -412,3 +422,4 @@ impl Stores {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ impl BlockWindow {
|
||||
|
||||
/// Number of windows in this range.
|
||||
fn count(&self) -> usize {
|
||||
(self.end - self.start + self.window - 1) / self.window
|
||||
(self.end - self.start).div_ceil(self.window)
|
||||
}
|
||||
|
||||
/// Iterate windows, yielding (avg_height, window_start, window_end) for each.
|
||||
|
||||
@@ -358,7 +358,7 @@ impl Query {
|
||||
entries.extend(Self::compute_hashrate_entries(
|
||||
&shared,
|
||||
&pool_cum,
|
||||
&pool.name,
|
||||
pool.name,
|
||||
SAMPLE_WEEKLY,
|
||||
));
|
||||
}
|
||||
@@ -459,18 +459,18 @@ impl Query {
|
||||
let h_prev = shared.first_heights[i - LOOKBACK_DAYS].to_usize();
|
||||
let total_blocks = h_now.saturating_sub(h_prev);
|
||||
|
||||
if total_blocks > 0 {
|
||||
if let Some(hr) = shared.daily_hashrate[i].as_ref() {
|
||||
let network_hr = f64::from(**hr);
|
||||
let share = pool_blocks as f64 / total_blocks as f64;
|
||||
let day = Day1::from(shared.start_day + i);
|
||||
entries.push(PoolHashrateEntry {
|
||||
timestamp: day.to_timestamp(),
|
||||
avg_hashrate: (network_hr * share) as u128,
|
||||
share,
|
||||
pool_name: pool_name.to_string(),
|
||||
});
|
||||
}
|
||||
if total_blocks > 0
|
||||
&& let Some(hr) = shared.daily_hashrate[i].as_ref()
|
||||
{
|
||||
let network_hr = **hr;
|
||||
let share = pool_blocks as f64 / total_blocks as f64;
|
||||
let day = Day1::from(shared.start_day + i);
|
||||
entries.push(PoolHashrateEntry {
|
||||
timestamp: day.to_timestamp(),
|
||||
avg_hashrate: (network_hr * share) as u128,
|
||||
share,
|
||||
pool_name: pool_name.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ impl Client {
|
||||
let witness = txin
|
||||
.witness
|
||||
.iter()
|
||||
.map(|w| bitcoin::hex::DisplayHex::to_lower_hex_string(w))
|
||||
.map(bitcoin::hex::DisplayHex::to_lower_hex_string)
|
||||
.collect();
|
||||
|
||||
Ok(TxIn {
|
||||
|
||||
@@ -16,3 +16,7 @@ brk_types = { workspace = true }
|
||||
byteview = { workspace = true }
|
||||
fjall = { workspace = true }
|
||||
rustc-hash = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rayon.workspace = true
|
||||
tempfile = "3.27.0"
|
||||
|
||||
365
crates/brk_store/tests/fjall_repro.rs
Normal file
365
crates/brk_store/tests/fjall_repro.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
//! Minimal reproduction: data written via start_ingestion is lost after close+reopen.
|
||||
//!
|
||||
//! This mimics what brk does:
|
||||
//! 1. Open database with manual_journal_persist
|
||||
//! 2. Create a keyspace (Kind::Recent config)
|
||||
//! 3. Use start_ingestion to bulk-write data
|
||||
//! 4. Call persist(SyncData)
|
||||
//! 5. Drop the database
|
||||
//! 6. Reopen
|
||||
//! 7. Check if data survived
|
||||
|
||||
use brk_store::{Kind, Mode, Store};
|
||||
use brk_types::{Height, TxIndex, TxidPrefix, Version};
|
||||
use fjall::{Database, KeyspaceCreateOptions, PersistMode};
|
||||
|
||||
fn open_db(path: &std::path::Path) -> Database {
|
||||
Database::builder(path.join("fjall"))
|
||||
.cache_size(64 * 1024 * 1024)
|
||||
.open()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn open_keyspace(db: &Database) -> fjall::Keyspace {
|
||||
db.keyspace("test_keyspace", || {
|
||||
KeyspaceCreateOptions::default()
|
||||
.manual_journal_persist(true)
|
||||
.expect_point_read_hits(true)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingestion_survives_close_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
// Phase 1: write data via ingestion, then close
|
||||
{
|
||||
let db = open_db(path);
|
||||
let ks = open_keyspace(&db);
|
||||
|
||||
let mut ingestion = ks.start_ingestion().unwrap();
|
||||
for i in 0u64..1000 {
|
||||
ingestion
|
||||
.write(i.to_be_bytes(), i.to_be_bytes())
|
||||
.unwrap();
|
||||
}
|
||||
ingestion.finish().unwrap();
|
||||
|
||||
// Verify data is readable before close
|
||||
assert!(!ks.is_empty().unwrap(), "keyspace should have data before close");
|
||||
assert!(ks.get(0u64.to_be_bytes()).unwrap().is_some(), "key 0 should exist before close");
|
||||
|
||||
db.persist(PersistMode::SyncData).unwrap();
|
||||
|
||||
// db + ks dropped here
|
||||
}
|
||||
|
||||
// Phase 2: reopen and check
|
||||
{
|
||||
let db = open_db(path);
|
||||
let ks = open_keyspace(&db);
|
||||
|
||||
assert!(
|
||||
!ks.is_empty().unwrap(),
|
||||
"BUG: keyspace is empty after close+reopen — ingested data lost"
|
||||
);
|
||||
assert!(
|
||||
ks.get(0u64.to_be_bytes()).unwrap().is_some(),
|
||||
"BUG: key 0 missing after close+reopen"
|
||||
);
|
||||
assert!(
|
||||
ks.get(999u64.to_be_bytes()).unwrap().is_some(),
|
||||
"BUG: key 999 missing after close+reopen"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Same test but with a keyspace clone (mimics take_pending_ingest capturing keyspace.clone())
|
||||
#[test]
|
||||
fn ingestion_via_cloned_keyspace_survives_close_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
{
|
||||
let db = open_db(path);
|
||||
let ks = open_keyspace(&db);
|
||||
|
||||
// Clone the keyspace (like take_pending_ingest does)
|
||||
let ks_clone = ks.clone();
|
||||
|
||||
let mut ingestion = ks_clone.start_ingestion().unwrap();
|
||||
for i in 0u64..1000 {
|
||||
ingestion
|
||||
.write(i.to_be_bytes(), i.to_be_bytes())
|
||||
.unwrap();
|
||||
}
|
||||
ingestion.finish().unwrap();
|
||||
|
||||
// Clone used for persist (like fjall_db.persist in bg task)
|
||||
let db_clone = db.clone();
|
||||
db_clone.persist(PersistMode::SyncData).unwrap();
|
||||
|
||||
// Drop order mimics Indexer: ks_clone dropped first, then db_clone, then ks, then db
|
||||
drop(ks_clone);
|
||||
drop(db_clone);
|
||||
drop(ks);
|
||||
drop(db);
|
||||
}
|
||||
|
||||
{
|
||||
let db = open_db(path);
|
||||
let ks = open_keyspace(&db);
|
||||
|
||||
assert!(
|
||||
!ks.is_empty().unwrap(),
|
||||
"BUG: keyspace is empty after close+reopen — cloned ingestion data lost"
|
||||
);
|
||||
assert!(
|
||||
ks.get(500u64.to_be_bytes()).unwrap().is_some(),
|
||||
"BUG: key 500 missing after close+reopen (cloned keyspace path)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mimics brk at scale: 20+ keyspaces, parallel intermediate commits (like par_iter_any_mut),
|
||||
/// hundreds of batches, large data, bg thread ingest, drop-db-before-keyspaces order.
|
||||
#[test]
|
||||
fn many_keyspaces_parallel_commits_bg_ingest() {
|
||||
use rayon::prelude::*;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
const NUM_KEYSPACES: usize = 25;
|
||||
const INTERMEDIATE_BATCHES: u64 = 500;
|
||||
const KEYS_PER_BATCH: u64 = 10_000;
|
||||
const BG_KEYS_PER_KS: u64 = 10_000;
|
||||
|
||||
{
|
||||
let db = open_db(path);
|
||||
|
||||
let keyspaces: Vec<fjall::Keyspace> = (0..NUM_KEYSPACES)
|
||||
.map(|i| {
|
||||
db.keyspace(&format!("ks_{i}"), || {
|
||||
let mut opts = KeyspaceCreateOptions::default()
|
||||
.manual_journal_persist(true);
|
||||
// Mix configs like brk does (Kind::Recent vs Kind::Random vs Kind::Vec)
|
||||
if i % 3 == 0 {
|
||||
opts = opts.expect_point_read_hits(true);
|
||||
}
|
||||
opts
|
||||
})
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Intermediate commits — PARALLEL across keyspaces (like par_iter_any_mut)
|
||||
for batch in 0..INTERMEDIATE_BATCHES {
|
||||
keyspaces.par_iter().for_each(|ks| {
|
||||
let start = batch * KEYS_PER_BATCH;
|
||||
let end = start + KEYS_PER_BATCH;
|
||||
let mut ing = ks.start_ingestion().unwrap();
|
||||
for i in start..end {
|
||||
ing.write(i.to_be_bytes(), i.to_be_bytes()).unwrap();
|
||||
}
|
||||
ing.finish().unwrap();
|
||||
});
|
||||
db.persist(PersistMode::SyncData).unwrap();
|
||||
}
|
||||
|
||||
let total_intermediate = INTERMEDIATE_BATCHES * KEYS_PER_BATCH;
|
||||
eprintln!("Wrote {total_intermediate} keys/ks × {NUM_KEYSPACES} keyspaces in {INTERMEDIATE_BATCHES} parallel batches");
|
||||
|
||||
// take_pending_ingest: clone each keyspace + db, run on bg thread SEQUENTIALLY
|
||||
let ks_clones: Vec<_> = keyspaces.iter().map(|ks| ks.clone()).collect();
|
||||
let db_clone = db.clone();
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
// Sequential ingestion per keyspace (like `for task in tasks { task()?; }`)
|
||||
for ks_clone in &ks_clones {
|
||||
let start = total_intermediate;
|
||||
let end = start + BG_KEYS_PER_KS;
|
||||
let mut ing = ks_clone.start_ingestion().unwrap();
|
||||
for i in start..end {
|
||||
ing.write(i.to_be_bytes(), i.to_be_bytes()).unwrap();
|
||||
}
|
||||
ing.finish().unwrap();
|
||||
}
|
||||
|
||||
db_clone.persist(PersistMode::SyncData).unwrap();
|
||||
});
|
||||
|
||||
// sync_bg_tasks
|
||||
handle.join().unwrap();
|
||||
|
||||
// Stores drop order: db first, then keyspaces (struct field order)
|
||||
drop(db);
|
||||
drop(keyspaces);
|
||||
}
|
||||
|
||||
// Reopen and verify
|
||||
{
|
||||
let db = open_db(path);
|
||||
let total_intermediate = INTERMEDIATE_BATCHES * KEYS_PER_BATCH;
|
||||
|
||||
for i in 0..NUM_KEYSPACES {
|
||||
let ks = db
|
||||
.keyspace(&format!("ks_{i}"), || {
|
||||
KeyspaceCreateOptions::default().manual_journal_persist(true)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!ks.is_empty().unwrap(),
|
||||
"BUG: ks_{i} is empty after reopen"
|
||||
);
|
||||
|
||||
// Intermediate data
|
||||
assert!(
|
||||
ks.get(0u64.to_be_bytes()).unwrap().is_some(),
|
||||
"BUG: ks_{i} key 0 missing"
|
||||
);
|
||||
assert!(
|
||||
ks.get((total_intermediate - 1).to_be_bytes()).unwrap().is_some(),
|
||||
"BUG: ks_{i} key {} missing", total_intermediate - 1
|
||||
);
|
||||
|
||||
// Bg task data
|
||||
let bg_mid = total_intermediate + BG_KEYS_PER_KS / 2;
|
||||
assert!(
|
||||
ks.get(bg_mid.to_be_bytes()).unwrap().is_some(),
|
||||
"BUG: ks_{i} key {bg_mid} (bg) missing"
|
||||
);
|
||||
|
||||
// Spot checks across the full range
|
||||
for check in [1u64, 100, 1_000, 10_000, 100_000, 1_000_000, 4_999_999] {
|
||||
if check < total_intermediate + BG_KEYS_PER_KS {
|
||||
assert!(
|
||||
ks.get(check.to_be_bytes()).unwrap().is_some(),
|
||||
"BUG: ks_{i} key {check} missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("All {NUM_KEYSPACES} keyspaces verified after reopen");
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses the ACTUAL brk Store<TxidPrefix, TxIndex> type with commit + take_pending_ingest.
|
||||
/// This exercises the exact code path that brk uses.
|
||||
#[test]
|
||||
fn actual_store_commit_then_take_pending_ingest() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
let stores_path = path.join("stores");
|
||||
std::fs::create_dir_all(&stores_path).unwrap();
|
||||
|
||||
let version = Version::new(29); // MAJOR_FJALL_VERSION(3) + VERSION(26)
|
||||
|
||||
{
|
||||
let db = brk_store::open_database(&stores_path).unwrap();
|
||||
|
||||
let mut store: Store<TxidPrefix, TxIndex> = Store::import_cached(
|
||||
&db,
|
||||
&stores_path,
|
||||
"txid_prefix_to_tx_index",
|
||||
version,
|
||||
Mode::PushOnly,
|
||||
Kind::Recent,
|
||||
5,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Simulate intermediate commits (like Stores::commit every 1000 blocks)
|
||||
for batch in 0u64..500 {
|
||||
for i in (batch * 1000)..((batch + 1) * 1000) {
|
||||
let prefix = TxidPrefix::from(byteview::ByteView::from(i.to_be_bytes()));
|
||||
let tx_index = TxIndex::from(i as usize);
|
||||
store.insert(prefix, tx_index);
|
||||
}
|
||||
// AnyStore::commit
|
||||
brk_store::AnyStore::commit(&mut store, Height::from(batch as u32))?;
|
||||
db.persist(PersistMode::SyncData).unwrap();
|
||||
}
|
||||
|
||||
let total_intermediate = 500_000u64;
|
||||
|
||||
// Verify before take_pending_ingest
|
||||
let prefix_0 = TxidPrefix::from(byteview::ByteView::from(0u64.to_be_bytes()));
|
||||
assert!(store.get(&prefix_0).unwrap().is_some(), "key 0 should exist before take");
|
||||
|
||||
// Simulate take_pending_ingest: add more data, then take
|
||||
for i in total_intermediate..(total_intermediate + 5_000) {
|
||||
let prefix = TxidPrefix::from(byteview::ByteView::from(i.to_be_bytes()));
|
||||
let tx_index = TxIndex::from(i as usize);
|
||||
store.insert(prefix, tx_index);
|
||||
}
|
||||
|
||||
let task = store
|
||||
.take_pending_ingest(Height::from(943425u32))
|
||||
.unwrap();
|
||||
|
||||
// Simulate bg thread
|
||||
let db_clone = db.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
if let Some(task) = task {
|
||||
task().unwrap();
|
||||
}
|
||||
db_clone.persist(PersistMode::SyncData).unwrap();
|
||||
});
|
||||
handle.join().unwrap();
|
||||
|
||||
// Drop order: db first, then store (like Stores struct)
|
||||
drop(db);
|
||||
drop(store);
|
||||
}
|
||||
|
||||
// Reopen and verify
|
||||
{
|
||||
let db = brk_store::open_database(&stores_path).unwrap();
|
||||
|
||||
let store: Store<TxidPrefix, TxIndex> = Store::import_cached(
|
||||
&db,
|
||||
&stores_path,
|
||||
"txid_prefix_to_tx_index",
|
||||
version,
|
||||
Mode::PushOnly,
|
||||
Kind::Recent,
|
||||
5,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!store.is_empty().unwrap(),
|
||||
"BUG: store is empty after reopen"
|
||||
);
|
||||
|
||||
// Check intermediate data
|
||||
let prefix_0 = TxidPrefix::from(byteview::ByteView::from(0u64.to_be_bytes()));
|
||||
assert!(
|
||||
store.get(&prefix_0).unwrap().is_some(),
|
||||
"BUG: key 0 (intermediate) missing after reopen"
|
||||
);
|
||||
|
||||
let prefix_mid = TxidPrefix::from(byteview::ByteView::from(250_000u64.to_be_bytes()));
|
||||
assert!(
|
||||
store.get(&prefix_mid).unwrap().is_some(),
|
||||
"BUG: key 250000 (intermediate) missing after reopen"
|
||||
);
|
||||
|
||||
// Check bg task data
|
||||
let prefix_bg = TxidPrefix::from(byteview::ByteView::from(502_000u64.to_be_bytes()));
|
||||
assert!(
|
||||
store.get(&prefix_bg).unwrap().is_some(),
|
||||
"BUG: key 502000 (bg task) missing after reopen"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -54,6 +54,7 @@
|
||||
* @typedef {Brk._0sdM0M1M1sdM2M2sdM3sdP0P1P1sdP2P2sdP3sdSdZscorePattern} Ratio1ySdPattern
|
||||
* @typedef {Brk.Dollars} Dollars
|
||||
* @typedef {Brk.BlockInfo} BlockInfo
|
||||
* @typedef {Brk.BlockHash} BlockHash
|
||||
* @typedef {Brk.BlockInfoV1} BlockInfoV1
|
||||
* ActivePriceRatioPattern: ratio pattern with price (extended)
|
||||
* @typedef {Brk.BpsPriceRatioPattern} ActivePriceRatioPattern
|
||||
|
||||
@@ -4,13 +4,22 @@ import { brk } from "../client.js";
|
||||
/** @type {HTMLDivElement} */
|
||||
let chain;
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
let details;
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
let sentinel;
|
||||
|
||||
/** @type {Map<BlockHash, BlockInfoV1>} */
|
||||
const blocksByHash = new Map();
|
||||
|
||||
let newestHeight = -1;
|
||||
let oldestHeight = Infinity;
|
||||
let loading = false;
|
||||
|
||||
/** @type {HTMLDivElement | null} */
|
||||
let selectedCube = null;
|
||||
|
||||
/** @type {number | undefined} */
|
||||
let pollInterval;
|
||||
|
||||
@@ -32,9 +41,17 @@ export function init() {
|
||||
chain.id = "chain";
|
||||
explorerElement.append(chain);
|
||||
|
||||
const blocks = window.document.createElement("div");
|
||||
blocks.classList.add("blocks");
|
||||
chain.append(blocks);
|
||||
|
||||
details = window.document.createElement("div");
|
||||
details.id = "block-details";
|
||||
explorerElement.append(details);
|
||||
|
||||
sentinel = window.document.createElement("div");
|
||||
sentinel.classList.add("sentinel");
|
||||
chain.append(sentinel);
|
||||
blocks.append(sentinel);
|
||||
|
||||
// Infinite scroll: load older blocks when sentinel becomes visible
|
||||
new IntersectionObserver((entries) => {
|
||||
@@ -72,11 +89,14 @@ async function loadLatest() {
|
||||
|
||||
// First load: insert all blocks before sentinel
|
||||
if (newestHeight === -1) {
|
||||
for (const block of blocks) {
|
||||
sentinel.after(createBlockCube(block));
|
||||
const cubes = blocks.map((b) => createBlockCube(b));
|
||||
for (const cube of cubes) {
|
||||
sentinel.after(cube);
|
||||
}
|
||||
newestHeight = blocks[0].height;
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
// Select the tip by default
|
||||
selectCube(cubes[0]);
|
||||
} else {
|
||||
// Subsequent polls: prepend only new blocks
|
||||
const newBlocks = blocks.filter((b) => b.height > newestHeight);
|
||||
@@ -109,24 +129,124 @@ async function loadOlder() {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
/** @param {HTMLDivElement} cube */
|
||||
function selectCube(cube) {
|
||||
if (selectedCube) {
|
||||
selectedCube.classList.remove("selected");
|
||||
}
|
||||
selectedCube = cube;
|
||||
if (cube) {
|
||||
cube.classList.add("selected");
|
||||
const hash = cube.dataset.hash;
|
||||
if (hash) {
|
||||
renderDetails(blocksByHash.get(hash));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {BlockInfoV1 | undefined} block */
|
||||
function renderDetails(block) {
|
||||
details.innerHTML = "";
|
||||
if (!block) return;
|
||||
|
||||
const title = window.document.createElement("h1");
|
||||
title.textContent = "Block ";
|
||||
const titleCode = window.document.createElement("code");
|
||||
titleCode.append(createHeightElement(block.height));
|
||||
title.append(titleCode);
|
||||
details.append(title);
|
||||
|
||||
const extras = block.extras;
|
||||
|
||||
/** @type {[string, string][]} */
|
||||
const rows = [
|
||||
["Hash", block.id],
|
||||
["Previous Hash", block.previousblockhash],
|
||||
["Merkle Root", block.merkleRoot],
|
||||
["Timestamp", new Date(block.timestamp * 1000).toUTCString()],
|
||||
["Median Time", new Date(block.mediantime * 1000).toUTCString()],
|
||||
["Version", `0x${block.version.toString(16)}`],
|
||||
["Bits", block.bits.toString(16)],
|
||||
["Nonce", block.nonce.toLocaleString()],
|
||||
["Difficulty", Number(block.difficulty).toLocaleString()],
|
||||
["Size", `${(block.size / 1_000_000).toFixed(2)} MB`],
|
||||
["Weight", `${(block.weight / 1_000_000).toFixed(2)} MWU`],
|
||||
["Transactions", block.txCount.toLocaleString()],
|
||||
];
|
||||
|
||||
if (extras) {
|
||||
rows.push(
|
||||
["Pool", extras.pool.name],
|
||||
["Pool ID", extras.pool.id.toString()],
|
||||
["Pool Slug", extras.pool.slug],
|
||||
["Miner Names", extras.pool.minerNames || "N/A"],
|
||||
["Reward", `${(extras.reward / 1e8).toFixed(8)} BTC`],
|
||||
["Total Fees", `${(extras.totalFees / 1e8).toFixed(8)} BTC`],
|
||||
["Median Fee Rate", `${extras.medianFee.toFixed(2)} sat/vB`],
|
||||
["Avg Fee Rate", `${extras.avgFeeRate.toFixed(2)} sat/vB`],
|
||||
["Avg Fee", `${extras.avgFee.toLocaleString()} sat`],
|
||||
["Median Fee", `${extras.medianFeeAmt.toLocaleString()} sat`],
|
||||
["Fee Range", extras.feeRange.map((f) => f.toFixed(1)).join(", ") + " sat/vB"],
|
||||
["Fee Percentiles", extras.feePercentiles.map((f) => f.toLocaleString()).join(", ") + " sat"],
|
||||
["Avg Tx Size", `${extras.avgTxSize.toLocaleString()} B`],
|
||||
["Virtual Size", `${extras.virtualSize.toLocaleString()} vB`],
|
||||
["Inputs", extras.totalInputs.toLocaleString()],
|
||||
["Outputs", extras.totalOutputs.toLocaleString()],
|
||||
["Total Input Amount", `${(extras.totalInputAmt / 1e8).toFixed(8)} BTC`],
|
||||
["Total Output Amount", `${(extras.totalOutputAmt / 1e8).toFixed(8)} BTC`],
|
||||
["UTXO Set Change", extras.utxoSetChange.toLocaleString()],
|
||||
["UTXO Set Size", extras.utxoSetSize.toLocaleString()],
|
||||
["SegWit Txs", extras.segwitTotalTxs.toLocaleString()],
|
||||
["SegWit Size", `${extras.segwitTotalSize.toLocaleString()} B`],
|
||||
["SegWit Weight", `${extras.segwitTotalWeight.toLocaleString()} WU`],
|
||||
["Coinbase Address", extras.coinbaseAddress || "N/A"],
|
||||
["Coinbase Addresses", extras.coinbaseAddresses.join(", ") || "N/A"],
|
||||
["Coinbase Raw", extras.coinbaseRaw],
|
||||
["Coinbase Signature", extras.coinbaseSignature],
|
||||
["Coinbase Signature ASCII", extras.coinbaseSignatureAscii],
|
||||
["Header", extras.header],
|
||||
);
|
||||
}
|
||||
|
||||
for (const [label, value] of rows) {
|
||||
const row = window.document.createElement("div");
|
||||
row.classList.add("row");
|
||||
const labelElement = window.document.createElement("span");
|
||||
labelElement.classList.add("label");
|
||||
labelElement.textContent = label;
|
||||
const valueElement = window.document.createElement("span");
|
||||
valueElement.classList.add("value");
|
||||
valueElement.textContent = value;
|
||||
row.append(labelElement, valueElement);
|
||||
details.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {number} height */
|
||||
function createHeightElement(height) {
|
||||
const container = window.document.createElement("span");
|
||||
const str = height.toString();
|
||||
const spanPrefix = window.document.createElement("span");
|
||||
spanPrefix.style.opacity = "0.5";
|
||||
spanPrefix.style.userSelect = "none";
|
||||
spanPrefix.textContent = "#" + "0".repeat(7 - str.length);
|
||||
const spanHeight = window.document.createElement("span");
|
||||
spanHeight.textContent = str;
|
||||
container.append(spanPrefix, spanHeight);
|
||||
return container;
|
||||
}
|
||||
|
||||
/** @param {BlockInfoV1} block */
|
||||
function createBlockCube(block) {
|
||||
const { cubeElement, leftFaceElement, rightFaceElement, topFaceElement } =
|
||||
createCube();
|
||||
|
||||
// cubeElement.style.setProperty("--face-color", `var(--${color})`);
|
||||
cubeElement.dataset.hash = block.id;
|
||||
blocksByHash.set(block.id, block);
|
||||
cubeElement.addEventListener("click", () => selectCube(cubeElement));
|
||||
|
||||
const heightElement = window.document.createElement("p");
|
||||
const height = block.height.toString();
|
||||
const prefixLength = 7 - height.length;
|
||||
const spanPrefix = window.document.createElement("span");
|
||||
spanPrefix.style.opacity = "0.5";
|
||||
spanPrefix.style.userSelect = "none";
|
||||
heightElement.append(spanPrefix);
|
||||
spanPrefix.innerHTML = "#" + "0".repeat(prefixLength);
|
||||
const spanHeight = window.document.createElement("span");
|
||||
heightElement.append(spanHeight);
|
||||
spanHeight.innerHTML = height;
|
||||
heightElement.append(createHeightElement(block.height));
|
||||
rightFaceElement.append(heightElement);
|
||||
|
||||
const feesElement = window.document.createElement("div");
|
||||
|
||||
@@ -47,11 +47,11 @@ a {
|
||||
}
|
||||
|
||||
aside {
|
||||
min-width: 0;
|
||||
/*min-width: 0;*/
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
/*height: 100%;*/
|
||||
/*width: 100%;*/
|
||||
/*overflow-y: auto;*/
|
||||
flex: 1;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@@ -64,7 +64,7 @@ aside {
|
||||
}
|
||||
|
||||
body > &[hidden] {
|
||||
display: flex !important;
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ h3 {
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: instrument;
|
||||
font-family: var(--font-serif);
|
||||
letter-spacing: 0.05rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@ -36,12 +36,13 @@
|
||||
}
|
||||
|
||||
html {
|
||||
font-family:
|
||||
"Lilex", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family:
|
||||
Instrument, Charter, "Bitstream Charter", "Sitka Text", Cambria, serif;
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
#explorer {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
--cube: 4.5rem;
|
||||
|
||||
> * {
|
||||
padding: var(--main-padding);
|
||||
}
|
||||
|
||||
#chain {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: calc(var(--cube) * 0.66);
|
||||
padding: 2rem;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
.blocks {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: calc(var(--cube) * 0.75);
|
||||
margin-right: var(--cube);
|
||||
margin-top: calc(var(--cube) * -0.25);
|
||||
}
|
||||
|
||||
.cube {
|
||||
margin-left: calc(var(--cube) * -0.25);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: var(--cube);
|
||||
height: var(--cube);
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
--face-color: var(--border-color);
|
||||
@@ -42,14 +58,23 @@
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 50ms;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
&:hover {
|
||||
--face-color: var(--inv-border-color);
|
||||
color: var(--background-color);
|
||||
}
|
||||
&:active {
|
||||
|
||||
&:active,
|
||||
&.selected {
|
||||
color: var(--black);
|
||||
--face-color: var(--orange);
|
||||
}
|
||||
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.face {
|
||||
transform-origin: 0 0;
|
||||
position: absolute;
|
||||
@@ -98,4 +123,41 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#block-details {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
|
||||
h1 {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
code {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300 !important;
|
||||
font-family: Lilex;
|
||||
color: var(--off-color);
|
||||
letter-spacing: -0.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.label {
|
||||
opacity: 0.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.value {
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
--dark-gray: oklch(20% 0 0);
|
||||
--light-black: oklch(17.5% 0 0);
|
||||
--black: oklch(15% 0 0);
|
||||
/*oklch(0.2038 0.0076 196.57)*/
|
||||
--red: oklch(0.607 0.241 26.328);
|
||||
--orange: oklch(67.64% 0.191 44.41);
|
||||
--amber: oklch(0.7175 0.1835 64.199);
|
||||
@@ -34,6 +33,11 @@
|
||||
--inv-border-color: light-dark(var(--dark-gray), var(--light-gray));
|
||||
--off-border-color: light-dark(var(--dark-white), var(--light-black));
|
||||
|
||||
--font-mono: "Lilex", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
--font-serif: Instrument, Charter, "Bitstream Charter", "Sitka Text",
|
||||
Cambria, serif;
|
||||
|
||||
--font-size-xs: 0.75rem;
|
||||
--line-height-xs: calc(1 / 0.75);
|
||||
--font-size-sm: 0.875rem;
|
||||
|
||||
Reference in New Issue
Block a user