server: api + doc

This commit is contained in:
nym21
2025-10-09 17:24:44 +02:00
parent 6ad15221de
commit 1821d5d57b
38 changed files with 952 additions and 865 deletions
Generated
+3 -1
View File
@@ -504,6 +504,7 @@ dependencies = [
"brk_computer",
"brk_error",
"brk_fetcher",
"brk_grouper",
"brk_indexer",
"brk_interface",
"brk_logger",
@@ -512,6 +513,7 @@ dependencies = [
"brk_server",
"brk_store",
"brk_structs",
"brk_traversable",
]
[[package]]
@@ -710,6 +712,7 @@ dependencies = [
name = "brk_interface"
version = "0.0.111"
dependencies = [
"bitcoin",
"bitcoincore-rpc",
"brk_computer",
"brk_error",
@@ -1178,7 +1181,6 @@ dependencies = [
"quick_cache",
"schemars",
"serde",
"serde_json",
"sonic-rs 0.5.5",
"tokio",
"tower-http",
+10 -1
View File
@@ -10,12 +10,15 @@ rust-version.workspace = true
build = "build.rs"
[features]
default = ["cli"]
full = [
"binder",
"bundler",
"cli",
"computer",
"error",
"fetcher",
"grouper",
"indexer",
"interface",
"logger",
@@ -24,12 +27,15 @@ full = [
"server",
"store",
"structs",
"traversable",
]
binder = ["brk_binder"]
bundler = ["brk_bundler"]
cli = ["brk_cli"]
computer = ["brk_computer"]
error = ["brk_error"]
fetcher = ["brk_fetcher"]
grouper = ["brk_grouper"]
indexer = ["brk_indexer"]
interface = ["brk_interface"]
logger = ["brk_logger"]
@@ -38,14 +44,16 @@ parser = ["brk_parser"]
server = ["brk_server"]
store = ["brk_store"]
structs = ["brk_structs"]
traversable = ["brk_traversable"]
[dependencies]
brk_binder = { workspace = true, optional = true }
brk_bundler = { workspace = true, optional = true }
brk_cli = { workspace = true }
brk_cli = { workspace = true, optional = true }
brk_computer = { workspace = true, optional = true }
brk_error = { workspace = true, optional = true }
brk_fetcher = { workspace = true, optional = true }
brk_grouper = { workspace = true, optional = true }
brk_indexer = { workspace = true, optional = true }
brk_interface = { workspace = true, optional = true }
brk_logger = { workspace = true, optional = true }
@@ -54,6 +62,7 @@ brk_parser = { workspace = true, optional = true }
brk_server = { workspace = true, optional = true }
brk_store = { workspace = true, optional = true }
brk_structs = { workspace = true, optional = true }
brk_traversable = { workspace = true, optional = true }
[package.metadata.docs.rs]
all-features = true
+17 -8
View File
@@ -8,13 +8,10 @@ pub use brk_binder as binder;
#[doc(inline)]
pub use brk_bundler as bundler;
#[cfg(feature = "cli")]
#[doc(inline)]
pub use brk_cli as cli;
#[cfg(feature = "structs")]
#[doc(inline)]
pub use brk_structs as structs;
#[cfg(feature = "computer")]
#[doc(inline)]
pub use brk_computer as computer;
@@ -27,10 +24,18 @@ pub use brk_error as error;
#[doc(inline)]
pub use brk_fetcher as fetcher;
#[cfg(feature = "grouper")]
#[doc(inline)]
pub use brk_grouper as grouper;
#[cfg(feature = "indexer")]
#[doc(inline)]
pub use brk_indexer as indexer;
#[cfg(feature = "interface")]
#[doc(inline)]
pub use brk_interface as interface;
#[cfg(feature = "logger")]
#[doc(inline)]
pub use brk_logger as logger;
@@ -43,10 +48,6 @@ pub use brk_mcp as mcp;
#[doc(inline)]
pub use brk_parser as parser;
#[cfg(feature = "interface")]
#[doc(inline)]
pub use brk_interface as interface;
#[cfg(feature = "server")]
#[doc(inline)]
pub use brk_server as server;
@@ -54,3 +55,11 @@ pub use brk_server as server;
#[cfg(feature = "store")]
#[doc(inline)]
pub use brk_store as store;
#[cfg(feature = "structs")]
#[doc(inline)]
pub use brk_structs as structs;
#[cfg(feature = "traversable")]
#[doc(inline)]
pub use brk_traversable as traversable;
+18
View File
@@ -27,6 +27,14 @@ pub enum Error {
WrongAddressType,
UnindexableDate,
QuickCacheError,
InvalidAddress,
InvalidNetwork,
InvalidTxid,
UnknownAddress,
UnknownTxid,
UnsupportedType(String),
Str(&'static str),
String(String),
}
@@ -140,6 +148,16 @@ impl fmt::Display for Error {
"Date cannot be indexed, must be 2009-01-03, 2009-01-09 or greater"
),
Error::InvalidTxid => write!(f, "The provided TXID appears to be invalid"),
Error::InvalidNetwork => write!(f, "Invalid network"),
Error::InvalidAddress => write!(f, "The provided address appears to be invalid"),
Error::UnknownAddress => write!(
f,
"Address not found in the blockchain (no transaction history)"
),
Error::UnknownTxid => write!(f, "Failed to find the TXID in the blockchain"),
Error::UnsupportedType(t) => write!(f, "Unsupported type ({t})"),
Error::Str(s) => write!(f, "{s}"),
Error::String(s) => write!(f, "{s}"),
}
+1
View File
@@ -10,6 +10,7 @@ rust-version.workspace = true
build = "build.rs"
[dependencies]
bitcoin = { workspace = true }
bitcoincore-rpc = { workspace = true }
brk_computer = { workspace = true }
brk_error = { workspace = true }
+124
View File
@@ -0,0 +1,124 @@
use std::str::FromStr;
use bitcoin::{Address, Network, PublicKey, ScriptBuf};
use brk_error::{Error, Result};
use brk_structs::{
AddressBytes, AddressBytesHash, AddressInfo, AddressPath, AnyAddressDataIndexEnum, Bitcoin,
OutputType,
};
use vecdb::{AnyIterableVec, VecIterator};
use crate::Interface;
pub fn get_address_info(
AddressPath { address }: AddressPath,
interface: &Interface,
) -> Result<AddressInfo> {
let indexer = interface.indexer();
let computer = interface.computer();
let stores = &indexer.stores;
let script = if let Ok(address) = Address::from_str(&address) {
if !address.is_valid_for_network(Network::Bitcoin) {
return Err(Error::InvalidNetwork);
}
let address = address.assume_checked();
address.script_pubkey()
} else if let Ok(pubkey) = PublicKey::from_str(&address) {
ScriptBuf::new_p2pk(&pubkey)
} else {
return Err(Error::InvalidAddress);
};
let type_ = OutputType::from(&script);
let Ok(bytes) = AddressBytes::try_from((&script, type_)) else {
return Err(Error::Str("Failed to convert the address to bytes"));
};
let hash = AddressBytesHash::from((&bytes, type_));
let Ok(Some(type_index)) = stores
.addressbyteshash_to_typeindex
.get(&hash)
.map(|opt| opt.map(|cow| cow.into_owned()))
else {
return Err(Error::UnknownAddress);
};
let stateful = &computer.stateful;
let price = computer.price.as_ref().map(|v| {
*v.timeindexes_to_price_close
.dateindex
.as_ref()
.unwrap()
.iter()
.last()
.unwrap()
.1
.into_owned()
});
let any_address_index = match type_ {
OutputType::P2PK33 => stateful
.p2pk33addressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2PK65 => stateful
.p2pk65addressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2PKH => stateful
.p2pkhaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2SH => stateful
.p2shaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2TR => stateful
.p2traddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2WPKH => stateful
.p2wpkhaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2WSH => stateful
.p2wshaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2A => stateful
.p2aaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
t => {
return Err(Error::UnsupportedType(t.to_string()));
}
};
let address_data = match any_address_index.to_enum() {
AnyAddressDataIndexEnum::Loaded(index) => stateful
.loadedaddressindex_to_loadedaddressdata
.iter()
.unwrap_get_inner(index),
AnyAddressDataIndexEnum::Empty(index) => stateful
.emptyaddressindex_to_emptyaddressdata
.iter()
.unwrap_get_inner(index)
.into(),
};
let balance = address_data.balance();
Ok(AddressInfo {
address: address.to_string(),
r#type: type_,
type_index,
utxo_count: address_data.utxo_count,
total_sent: address_data.sent,
total_received: address_data.received,
balance,
balance_usd: price.map(|p| p * Bitcoin::from(balance)),
estimated_total_invested: price.map(|_| address_data.realized_cap),
estimated_avg_entry_price: price.map(|_| address_data.realized_price()),
})
}
+5
View File
@@ -0,0 +1,5 @@
mod addresses;
mod transactions;
pub use addresses::*;
pub use transactions::*;
@@ -0,0 +1,87 @@
use std::{
fs::File,
io::{Cursor, Read, Seek, SeekFrom},
str::FromStr,
};
use bitcoin::{Transaction, consensus::Decodable};
use brk_error::{Error, Result};
use brk_parser::XORIndex;
use brk_structs::{TransactionInfo, Txid, TxidPath, TxidPrefix};
use vecdb::VecIterator;
use crate::Interface;
pub fn get_transaction_info(
TxidPath { txid }: TxidPath,
interface: &Interface,
) -> Result<TransactionInfo> {
let Ok(txid) = bitcoin::Txid::from_str(&txid) else {
return Err(Error::InvalidTxid);
};
let txid = Txid::from(txid);
let prefix = TxidPrefix::from(&txid);
let indexer = interface.indexer();
let Ok(Some(index)) = indexer
.stores
.txidprefix_to_txindex
.get(&prefix)
.map(|opt| opt.map(|cow| cow.into_owned()))
else {
return Err(Error::UnknownTxid);
};
let txid = indexer.vecs.txindex_to_txid.iter().unwrap_get_inner(index);
let parser = interface.parser();
let computer = interface.computer();
let position = computer
.blks
.txindex_to_position
.iter()
.unwrap_get_inner(index);
let len = indexer
.vecs
.txindex_to_total_size
.iter()
.unwrap_get_inner(index);
let blk_index_to_blk_path = parser.blk_index_to_blk_path();
let Some(blk_path) = blk_index_to_blk_path.get(&position.blk_index()) else {
return Err(Error::Str("Failed to get the correct blk file"));
};
let mut xori = XORIndex::default();
xori.add_assign(position.offset() as usize);
let Ok(mut file) = File::open(blk_path) else {
return Err(Error::Str("Failed to open blk file"));
};
if file
.seek(SeekFrom::Start(position.offset() as u64))
.is_err()
{
return Err(Error::Str("Failed to seek position in file"));
}
let mut buffer = vec![0u8; *len as usize];
if file.read_exact(&mut buffer).is_err() {
return Err(Error::Str("Failed to read the transaction (read exact)"));
}
xori.bytes(&mut buffer, parser.xor_bytes());
let mut reader = Cursor::new(buffer);
let Ok(_) = Transaction::consensus_decode(&mut reader) else {
return Err(Error::Str("Failed decode the transaction"));
};
Ok(TransactionInfo {
txid,
index,
// tx
})
}
+18 -8
View File
@@ -6,7 +6,10 @@ use brk_computer::Computer;
use brk_error::{Error, Result};
use brk_indexer::Indexer;
use brk_parser::Parser;
use brk_structs::{Height, Index, IndexInfo};
use brk_structs::{
AddressInfo, AddressPath, Format, Height, Index, IndexInfo, MetricCount, TransactionInfo,
TxidPath,
};
use brk_traversable::TreeNode;
use nucleo_matcher::{
Config, Matcher,
@@ -15,23 +18,22 @@ use nucleo_matcher::{
use quick_cache::sync::Cache;
use vecdb::{AnyCollectableVec, AnyStoredVec};
mod count;
mod chain;
mod deser;
mod format;
mod metrics;
mod output;
mod pagination;
mod params;
mod vecs;
pub use count::*;
pub use format::Format;
pub use output::{Output, Value};
pub use metrics::{Output, Value};
pub use pagination::{PaginatedIndexParam, PaginatedMetrics, PaginationParam};
pub use params::{Params, ParamsDeprec, ParamsOpt};
use vecs::Vecs;
use crate::vecs::{IndexToVec, MetricToVec};
use crate::{
chain::{get_address_info, get_transaction_info},
vecs::{IndexToVec, MetricToVec},
};
pub fn cached_errors() -> &'static Cache<String, String> {
static CACHE: OnceLock<Cache<String, String>> = OnceLock::new();
@@ -65,6 +67,14 @@ impl<'a> Interface<'a> {
Height::from(self.indexer.vecs.height_to_blockhash.stamp())
}
pub fn get_address_info(&self, address: AddressPath) -> Result<AddressInfo> {
get_address_info(address, self)
}
pub fn get_transaction_info(&self, txid: TxidPath) -> Result<TransactionInfo> {
get_transaction_info(txid, self)
}
pub fn search(&self, params: &Params) -> Result<Vec<(String, &&dyn AnyCollectableVec)>> {
let metrics = &params.metrics;
let index = params.index;
@@ -3,7 +3,10 @@ use std::fmt;
use derive_deref::Deref;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
mod output;
pub use output::*;
#[derive(Debug, Deref, JsonSchema)]
pub struct MaybeMetrics(Vec<String>);
@@ -33,7 +36,7 @@ impl<'de> Deserialize<'de> for MaybeMetrics {
where
D: serde::Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
let value = serde_json::Value::deserialize(deserializer)?;
if let Some(str) = value.as_str() {
if str.len() <= MAX_STRING_SIZE {
@@ -1,4 +1,4 @@
use crate::Format;
use brk_structs::Format;
#[derive(Debug)]
pub enum Output {
+1 -2
View File
@@ -1,11 +1,10 @@
use std::ops::Deref;
use brk_structs::Index;
use brk_structs::{Format, Index};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
Format,
deser::{de_unquote_i64, de_unquote_usize},
metrics::MaybeMetrics,
};
+2 -2
View File
@@ -86,8 +86,8 @@ impl<'a> Vecs<'a> {
this.catalog.replace(
TreeNode::Branch(
[
("indexer".to_string(), indexer.vecs.to_tree_node()),
("computer".to_string(), computer.to_tree_node()),
("indexed".to_string(), indexer.vecs.to_tree_node()),
("computed".to_string(), computer.to_tree_node()),
]
.into_iter()
.collect(),
-1
View File
@@ -30,7 +30,6 @@ log = { workspace = true }
quick_cache = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sonic-rs = { workspace = true }
tokio = { workspace = true }
tower-http = { version = "0.6.6", features = ["compression-full", "trace"] }
+22 -228
View File
@@ -1,239 +1,16 @@
use std::str::FromStr;
use aide::{
axum::{ApiRouter, routing::get_with},
transform::TransformOperation,
};
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::Response,
};
use bitcoin::{Address as BitcoinAddress, Network, PublicKey, ScriptBuf};
use brk_structs::{
AddressBytes, AddressBytesHash, AnyAddressDataIndexEnum, Bitcoin, Dollars, OutputType, Sats,
TypeIndex,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use vecdb::{AnyIterableVec, VecIterator};
use brk_structs::{AddressInfo, AddressPath};
use crate::extended::TransformResponseExtended;
use crate::extended::{ResponseExtended, ResultExtended, TransformResponseExtended};
use super::AppState;
#[derive(Debug, Serialize, JsonSchema)]
/// Address information
struct AddressInfo {
/// Bitcoin address string
#[schemars(example = &"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f")]
address: String,
#[schemars(example = OutputType::P2PK65)]
r#type: OutputType,
#[schemars(example = TypeIndex::new(0))]
type_index: TypeIndex,
/// Total satoshis ever sent from this address
#[schemars(example = Sats::new(0))]
total_sent: Sats,
/// Total satoshis ever received by this address
#[schemars(example = Sats::new(5001008380))]
total_received: Sats,
/// Number of unspent transaction outputs (UTXOs)
#[schemars(example = 10)]
utxo_count: u32,
/// Current spendable balance in satoshis (total_received - total_sent)
#[schemars(example = Sats::new(5001008380))]
balance: Sats,
/// Current balance value in USD at current market price
#[schemars(example = Some(Dollars::mint(6_157_891.64)))]
balance_usd: Option<Dollars>,
/// Estimated total USD value at time of deposit for coins currently in this address (not including coins that were later sent out). Not suitable for tax calculations
#[schemars(example = Some(Dollars::mint(6.2)))]
estimated_total_invested: Option<Dollars>,
/// Estimated average BTC price at time of deposit for coins currently in this address (USD). Not suitable for tax calculations
#[schemars(example = Some(Dollars::mint(0.12)))]
estimated_avg_entry_price: Option<Dollars>,
//
// Transaction count?
// First/last activity timestamps?
// Realized/unrealized gains?
// Current value (balance × current price)?
// "address": address,
// "type": output_type,
// "index": addri,
// "chain_stats": {
// "funded_txo_count": null,
// "funded_txo_sum": addr_data.received,
// "spent_txo_count": null,
// "spent_txo_sum": addr_data.sent,
// "utxo_count": addr_data.utxos,
// "balance": amount,
// "balance_usd": price.map_or(Value::new(), |p| {
// Value::from(Number::from_f64(*(p * Bitcoin::from(amount))).unwrap())
// }),
// "realized_value": addr_data.realized_cap,
// "tx_count": null,
// "avg_cost_basis": addr_data.realized_price()
// },
// "mempool_stats": null
}
#[derive(Deserialize, JsonSchema)]
struct AddressPath {
/// Bitcoin address string
#[schemars(example = &"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f")]
address: String,
}
async fn get_address_info(
Path(AddressPath { address }): Path<AddressPath>,
state: State<AppState>,
) -> Result<Json<AddressInfo>, (StatusCode, Json<&'static str>)> {
let interface = state.interface;
let indexer = interface.indexer();
let computer = interface.computer();
let stores = &indexer.stores;
let script = if let Ok(address) = BitcoinAddress::from_str(&address) {
if !address.is_valid_for_network(Network::Bitcoin) {
return Err((
StatusCode::BAD_REQUEST,
Json("The provided address isn't the Bitcoin Network."),
));
}
let address = address.assume_checked();
address.script_pubkey()
} else if let Ok(pubkey) = PublicKey::from_str(&address) {
ScriptBuf::new_p2pk(&pubkey)
} else {
return Err((
StatusCode::BAD_REQUEST,
Json("The provided address is invalid."),
));
};
let type_ = OutputType::from(&script);
let Ok(bytes) = AddressBytes::try_from((&script, type_)) else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed to convert the address to bytes"),
));
};
let hash = AddressBytesHash::from((&bytes, type_));
let Ok(Some(type_index)) = stores
.addressbyteshash_to_typeindex
.get(&hash)
.map(|opt| opt.map(|cow| cow.into_owned()))
else {
return Err((
StatusCode::NOT_FOUND,
Json("Address not found in the blockchain (no transaction history)"),
));
};
let stateful = &computer.stateful;
let price = computer.price.as_ref().map(|v| {
*v.timeindexes_to_price_close
.dateindex
.as_ref()
.unwrap()
.iter()
.last()
.unwrap()
.1
.into_owned()
});
let any_address_index = match type_ {
OutputType::P2PK33 => stateful
.p2pk33addressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2PK65 => stateful
.p2pk65addressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2PKH => stateful
.p2pkhaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2SH => stateful
.p2shaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2TR => stateful
.p2traddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2WPKH => stateful
.p2wpkhaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2WSH => stateful
.p2wshaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2A => stateful
.p2aaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
_ => {
return Err((
StatusCode::BAD_REQUEST,
Json("The provided address uses an unsupported type"),
));
}
};
let address_data = match any_address_index.to_enum() {
AnyAddressDataIndexEnum::Loaded(index) => stateful
.loadedaddressindex_to_loadedaddressdata
.iter()
.unwrap_get_inner(index),
AnyAddressDataIndexEnum::Empty(index) => stateful
.emptyaddressindex_to_emptyaddressdata
.iter()
.unwrap_get_inner(index)
.into(),
};
let balance = address_data.balance();
Ok(Json(AddressInfo {
address: address.to_string(),
r#type: type_,
type_index,
utxo_count: address_data.utxo_count,
total_sent: address_data.sent,
total_received: address_data.received,
balance,
balance_usd: price.map(|p| p * Bitcoin::from(balance)),
estimated_total_invested: price.map(|_| address_data.realized_cap),
estimated_avg_entry_price: price.map(|_| address_data.realized_price()),
}))
}
fn get_address_info_docs(op: TransformOperation) -> TransformOperation {
op.tag("Chain")
.summary("Address information")
.description("Retrieve comprehensive information about a Bitcoin address including balance, transaction history, UTXOs, and estimated investment metrics. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR, etc.).")
.with_ok_response::<AddressInfo, _>(|res| res)
.with_not_modified()
.with_bad_request()
.with_not_found()
.with_server_error()
}
pub trait AddressesRoutes {
fn add_addresses_routes(self) -> Self;
}
@@ -242,7 +19,24 @@ impl AddressesRoutes for ApiRouter<AppState> {
fn add_addresses_routes(self) -> Self {
self.api_route(
"/api/chain/address/{address}",
get_with(get_address_info, get_address_info_docs),
get_with(async |Path(address): Path<AddressPath>,
State(app_state): State<AppState>|
-> Result<Response, (StatusCode, Json<String>)> {
let address_info = app_state.interface.get_address_info(address).to_server_result()?;
let bytes = sonic_rs::to_vec(&address_info).unwrap();
Ok(Response::new_json_from_bytes(bytes))
}, |op| op
.tag("Chain")
.summary("Address information")
.description("Retrieve comprehensive information about a Bitcoin address including balance, transaction history, UTXOs, and estimated investment metrics. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR, etc.).")
.with_ok_response::<AddressInfo, _>(|res| res)
.with_not_modified()
.with_bad_request()
.with_not_found()
.with_server_error()
),
)
}
}
+25 -149
View File
@@ -1,161 +1,16 @@
use std::{
fs::File,
io::{Cursor, Read, Seek, SeekFrom},
str::FromStr,
};
use aide::{
axum::{ApiRouter, routing::get_with},
transform::TransformOperation,
};
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::Response,
};
use bitcoin::{Transaction as BitcoinTransaction, consensus::Decodable};
use brk_parser::XORIndex;
use brk_structs::{TxIndex, Txid, TxidPrefix};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use vecdb::VecIterator;
use brk_structs::{TransactionInfo, TxidPath};
use crate::extended::{ResponseExtended, TransformResponseExtended};
use crate::extended::{ResponseExtended, ResultExtended, TransformResponseExtended};
use super::AppState;
#[derive(Serialize, JsonSchema)]
/// Transaction Information
struct TransactionInfo {
#[schemars(
with = "String",
example = "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"
)]
txid: Txid,
#[schemars(example = TxIndex::new(0))]
index: TxIndex,
#[serde(flatten)]
#[schemars(with = "serde_json::Value")]
tx: BitcoinTransaction,
}
#[derive(Deserialize, JsonSchema)]
struct TxidPath {
/// Bitcoin transaction id
#[schemars(example = &"4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b")]
txid: String,
}
async fn get_transaction_info(
Path(TxidPath { txid }): Path<TxidPath>,
state: State<AppState>,
) -> Result<Response, (StatusCode, Json<&'static str>)> {
let Ok(txid) = bitcoin::Txid::from_str(&txid) else {
return Err((
StatusCode::BAD_REQUEST,
Json("The provided TXID appears to be invalid."),
));
};
let txid = Txid::from(txid);
let prefix = TxidPrefix::from(&txid);
let interface = state.interface;
let indexer = interface.indexer();
let Ok(Some(index)) = indexer
.stores
.txidprefix_to_txindex
.get(&prefix)
.map(|opt| opt.map(|cow| cow.into_owned()))
else {
return Err((
StatusCode::NOT_FOUND,
Json("Failed to found the TXID in the blockchain."),
));
};
let txid = indexer.vecs.txindex_to_txid.iter().unwrap_get_inner(index);
let parser = interface.parser();
let computer = interface.computer();
let position = computer
.blks
.txindex_to_position
.iter()
.unwrap_get_inner(index);
let len = indexer
.vecs
.txindex_to_total_size
.iter()
.unwrap_get_inner(index);
let blk_index_to_blk_path = parser.blk_index_to_blk_path();
let Some(blk_path) = blk_index_to_blk_path.get(&position.blk_index()) else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed to read the transaction (get blk's path)"),
));
};
let mut xori = XORIndex::default();
xori.add_assign(position.offset() as usize);
let Ok(mut file) = File::open(blk_path) else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed to read the transaction (open file)"),
));
};
if file
.seek(SeekFrom::Start(position.offset() as u64))
.is_err()
{
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed to read the transaction (file seek)"),
));
}
let mut buffer = vec![0u8; *len as usize];
if file.read_exact(&mut buffer).is_err() {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed to read the transaction (read exact)"),
));
}
xori.bytes(&mut buffer, parser.xor_bytes());
let mut reader = Cursor::new(buffer);
let Ok(tx) = BitcoinTransaction::consensus_decode(&mut reader) else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed decode the transaction"),
));
};
let tx_info = TransactionInfo { txid, index, tx };
let bytes = sonic_rs::to_vec(&tx_info).unwrap();
Ok(Response::new_json_from_bytes(bytes))
}
fn get_transaction_info_docs(op: TransformOperation) -> TransformOperation {
op.tag("Chain")
.summary("Transaction information")
.description(
"Retrieve complete transaction data by transaction ID (txid). Returns the full transaction details including inputs, outputs, and metadata. The transaction data is read directly from the blockchain data files.",
)
.with_ok_response::<TransactionInfo, _>(|res| res)
.with_not_modified()
.with_bad_request()
.with_not_found()
.with_server_error()
}
pub trait TransactionsRoutes {
fn add_transactions_routes(self) -> Self;
}
@@ -164,7 +19,28 @@ impl TransactionsRoutes for ApiRouter<AppState> {
fn add_transactions_routes(self) -> Self {
self.api_route(
"/api/chain/tx/{txid}",
get_with(get_transaction_info, get_transaction_info_docs),
get_with(
async |Path(txid): Path<TxidPath>,
State(app_state): State<AppState>|
-> Result<Response, (StatusCode, Json<String>)> {
let tx_info = app_state.interface.get_transaction_info(txid).to_server_result()?;
let bytes = sonic_rs::to_vec(&tx_info).unwrap();
Ok(Response::new_json_from_bytes(bytes))
},
|op| op
.tag("Chain")
.summary("Transaction information")
.description(
"Retrieve complete transaction data by transaction ID (txid). Returns the full transaction details including inputs, outputs, and metadata. The transaction data is read directly from the blockchain data files.",
)
.with_ok_response::<TransactionInfo, _>(|res| res)
.with_not_modified()
.with_bad_request()
.with_not_found()
.with_server_error(),
),
)
}
}
+2 -1
View File
@@ -8,7 +8,8 @@ use axum::{
response::{IntoResponse, Response},
};
use brk_error::{Error, Result};
use brk_interface::{Format, Output, Params};
use brk_interface::{Output, Params};
use brk_structs::Format;
use quick_cache::sync::GuardResult;
use vecdb::Stamp;
+2 -13
View File
@@ -6,13 +6,9 @@ use axum::{
response::{IntoResponse, Redirect, Response},
routing::get,
};
use brk_interface::{
MetricCount, PaginatedMetrics, PaginationParam, Params, ParamsDeprec, ParamsOpt,
};
use brk_structs::{Index, IndexInfo};
use brk_interface::{PaginatedMetrics, PaginationParam, Params, ParamsDeprec, ParamsOpt};
use brk_structs::{Index, IndexInfo, MetricCount, MetricPath};
use brk_traversable::TreeNode;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
VERSION,
@@ -27,13 +23,6 @@ pub trait ApiMetricsRoutes {
fn add_metrics_routes(self) -> Self;
}
#[derive(Deserialize, JsonSchema)]
struct MetricPath {
/// Metric name
#[schemars(example = &"price_close", example = &"market_cap", example = &"realized_price")]
metric: String,
}
const TO_SEPARATOR: &str = "_to_";
impl ApiMetricsRoutes for ApiRouter<AppState> {
+6 -11
View File
@@ -10,8 +10,7 @@ use axum::{
response::{Html, Redirect, Response},
routing::get,
};
use schemars::JsonSchema;
use serde::Serialize;
use brk_structs::Health;
use crate::{
VERSION,
@@ -31,14 +30,6 @@ pub trait ApiRoutes {
fn add_api_routes(self) -> Self;
}
#[derive(Debug, Serialize, JsonSchema)]
/// Server health status
struct Health {
status: String,
service: String,
timestamp: String,
}
impl ApiRoutes for ApiRouter<AppState> {
fn add_api_routes(self) -> Self {
self.add_chain_routes()
@@ -62,7 +53,7 @@ impl ApiRoutes for ApiRouter<AppState> {
async || -> Json<Health> {
Json(Health {
status: "healthy".to_string(),
service: "brk-server".to_string(),
service: "brk".to_string(),
timestamp: jiff::Timestamp::now().to_string(),
})
},
@@ -101,5 +92,9 @@ impl ApiRoutes for ApiRouter<AppState> {
),
)
.route("/api", get(Html::from(include_str!("./scalar.html"))))
.route(
"/api/{*path}",
get(|| async { Redirect::permanent("/api") }),
)
}
}
+12 -10
View File
@@ -15,6 +15,17 @@ use aide::openapi::{Info, OpenApi, Tag};
use crate::VERSION;
pub fn create_openapi() -> OpenApi {
let info = Info {
title: "Bitcoin Research Kit".to_string(),
description: Some(
"API for querying Bitcoin blockchain data including addresses, transactions, and chain statistics. This API provides low-level access to indexed blockchain data with advanced analytics capabilities.\n\n\
⚠️ **Early Development**: This API is in very early stages of development. Breaking changes may occur without notice. For a more stable experience, [self-host](/install) or use the [hosting service](/service)."
.to_string(),
),
version: format!("v{VERSION}"),
..Info::default()
};
let tags = vec![
Tag {
name: "Chain".to_string(),
@@ -44,16 +55,7 @@ pub fn create_openapi() -> OpenApi {
];
OpenApi {
info: Info {
title: "Bitcoin Research Kit API".to_string(),
description: Some(
"API for querying Bitcoin blockchain data including addresses, transactions, and chain statistics. This API provides low-level access to indexed blockchain data with advanced analytics capabilities.\n\n\
⚠️ **Early Development**: This API is in very early stages of development. Breaking changes may occur without notice. For a more stable experience, self-host or use the hosting service."
.to_string(),
),
version: format!("v{VERSION}"),
..Info::default()
},
info,
tags,
..OpenApi::default()
}
+2
View File
@@ -1,7 +1,9 @@
mod header_map;
mod response;
mod result;
mod transform_operation;
pub use header_map::*;
pub use response::*;
pub use result::*;
pub use transform_operation::*;
+24
View File
@@ -0,0 +1,24 @@
use axum::{Json, http::StatusCode};
use brk_error::{Error, Result};
pub trait ResultExtended<T> {
fn to_server_result(self) -> Result<T, (StatusCode, Json<String>)>;
}
impl<T> ResultExtended<T> for Result<T> {
fn to_server_result(self) -> Result<T, (StatusCode, Json<String>)> {
self.map_err(|e| {
(
match e {
Error::InvalidTxid
| Error::InvalidNetwork
| Error::InvalidAddress
| Error::UnsupportedType(_) => StatusCode::BAD_REQUEST,
Error::UnknownAddress | Error::UnknownTxid => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
},
Json(e.to_string()),
)
})
}
}
+7 -6
View File
@@ -99,25 +99,26 @@ impl Server {
let router = ApiRouter::new()
.add_api_routes()
.add_files_routes(state.path.as_ref())
.add_mcp_routes(state.interface, mcp)
.add_files_routes(state.path.as_ref())
.route(
"/discord",
get(Redirect::temporary("https://discord.gg/WACpShCB7M")),
)
.route("/crates", get(Redirect::temporary("https://crates.io/crates/brk")))
.route("/crate", get(Redirect::temporary("https://crates.io/crates/brk")))
.route(
"/status",
get(Redirect::temporary("https://status.bitview.space")),
)
.route("/github", get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk")))
.route("/changelog", get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk/blob/main/docs/CHANGELOG.md")))
.route(
"/cli",
get(Redirect::temporary("https://crates.io/crates/brk_cli")),
"/install",
get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk/blob/main/crates/brk_cli/README.md#brk_cli")),
)
.route(
"/hosting",
get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk?tab=readme-ov-file#hosting-as-a-service")),
"/service",
get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk?tab=readme-ov-file#professional-hosting")),
)
.route("/nostr", get(Redirect::temporary("https://primal.net/p/npub1jagmm3x39lmwfnrtvxcs9ac7g300y3dusv9lgzhk2e4x5frpxlrqa73v44")))
.with_state(state)
+69
View File
@@ -0,0 +1,69 @@
use schemars::JsonSchema;
use serde::Serialize;
use crate::{Dollars, OutputType, Sats, TypeIndex};
#[derive(Debug, Serialize, JsonSchema)]
/// Address information
pub struct AddressInfo {
/// Bitcoin address string
#[schemars(example = &"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f")]
pub address: String,
#[schemars(example = OutputType::P2PK65)]
pub r#type: OutputType,
#[schemars(example = TypeIndex::new(0))]
pub type_index: TypeIndex,
/// Total satoshis ever sent from this address
#[schemars(example = Sats::new(0))]
pub total_sent: Sats,
/// Total satoshis ever received by this address
#[schemars(example = Sats::new(5001008380))]
pub total_received: Sats,
/// Number of unspent transaction outputs (UTXOs)
#[schemars(example = 10)]
pub utxo_count: u32,
/// Current spendable balance in satoshis (total_received - total_sent)
#[schemars(example = Sats::new(5001008380))]
pub balance: Sats,
/// Current balance value in USD at current market price
#[schemars(example = Some(Dollars::mint(6_157_891.64)))]
pub balance_usd: Option<Dollars>,
/// Estimated total USD value at time of deposit for coins currently in this address (not including coins that were later sent out). Not suitable for tax calculations
#[schemars(example = Some(Dollars::mint(6.2)))]
pub estimated_total_invested: Option<Dollars>,
/// Estimated average BTC price at time of deposit for coins currently in this address (USD). Not suitable for tax calculations
#[schemars(example = Some(Dollars::mint(0.12)))]
pub estimated_avg_entry_price: Option<Dollars>,
//
// Transaction count?
// First/last activity timestamps?
// Realized/unrealized gains?
// Current value (balance × current price)?
// "address": address,
// "type": output_type,
// "index": addri,
// "chain_stats": {
// "funded_txo_count": null,
// "funded_txo_sum": addr_data.received,
// "spent_txo_count": null,
// "spent_txo_sum": addr_data.sent,
// "utxo_count": addr_data.utxos,
// "balance": amount,
// "balance_usd": price.map_or(Value::new(), |p| {
// Value::from(Number::from_f64(*(p * Bitcoin::from(amount))).unwrap())
// }),
// "realized_value": addr_data.realized_cap,
// "tx_count": null,
// "avg_cost_basis": addr_data.realized_price()
// },
// "mempool_stats": null
}
+9
View File
@@ -0,0 +1,9 @@
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Deserialize, JsonSchema)]
pub struct AddressPath {
/// Bitcoin address string
#[schemars(example = &"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f")]
pub address: String,
}
+1 -1
View File
@@ -19,7 +19,7 @@ impl AnyAddressIndex {
impl From<LoadedAddressIndex> for AnyAddressIndex {
fn from(value: LoadedAddressIndex) -> Self {
if u32::from(value) >= MIN_EMPTY_INDEX {
panic!("")
panic!("{value} is higher than MIN_EMPTY_INDEX ({MIN_EMPTY_INDEX})")
}
Self(*value)
}
+10
View File
@@ -0,0 +1,10 @@
use schemars::JsonSchema;
use serde::Serialize;
#[derive(Debug, Serialize, JsonSchema)]
/// Server health status
pub struct Health {
pub status: String,
pub service: String,
pub timestamp: String,
}
+16
View File
@@ -6,6 +6,8 @@ use brk_error::{Error, Result};
mod addressbytes;
mod addressbyteshash;
mod addressinfo;
mod addresspath;
mod anyaddressindex;
mod bitcoin;
mod blkmetadata;
@@ -23,13 +25,17 @@ mod emptyaddressdata;
mod emptyaddressindex;
mod emptyoutputindex;
mod feerate;
mod format;
mod halvingepoch;
mod health;
mod height;
mod index;
mod indexinfo;
mod inputindex;
mod loadedaddressdata;
mod loadedaddressindex;
mod metriccount;
mod metricpath;
mod monthindex;
mod ohlc;
mod opreturnindex;
@@ -63,8 +69,10 @@ mod stored_u8;
mod timestamp;
mod treenode;
mod txid;
mod txidpath;
mod txidprefix;
mod txindex;
mod txinfo;
mod txversion;
mod typeindex;
mod typeindex_with_outputindex;
@@ -78,6 +86,8 @@ mod yearindex;
pub use addressbytes::*;
pub use addressbyteshash::*;
pub use addressinfo::*;
pub use addresspath::*;
pub use anyaddressindex::*;
pub use bitcoin::*;
pub use blkmetadata::*;
@@ -95,13 +105,17 @@ pub use emptyaddressdata::*;
pub use emptyaddressindex::*;
pub use emptyoutputindex::*;
pub use feerate::*;
pub use format::*;
pub use halvingepoch::*;
pub use health::*;
pub use height::*;
pub use index::*;
pub use indexinfo::*;
pub use inputindex::*;
pub use loadedaddressdata::*;
pub use loadedaddressindex::*;
pub use metriccount::*;
pub use metricpath::*;
pub use monthindex::*;
pub use ohlc::*;
pub use opreturnindex::*;
@@ -135,8 +149,10 @@ pub use stored_u64::*;
pub use timestamp::*;
pub use treenode::*;
pub use txid::*;
pub use txidpath::*;
pub use txidprefix::*;
pub use txindex::*;
pub use txinfo::*;
pub use txversion::*;
pub use typeindex::*;
pub use typeindex_with_outputindex::*;
+9
View File
@@ -0,0 +1,9 @@
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Deserialize, JsonSchema)]
pub struct MetricPath {
/// Metric name
#[schemars(example = &"price_close", example = &"market_cap", example = &"realized_price")]
pub metric: String,
}
+9
View File
@@ -0,0 +1,9 @@
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Deserialize, JsonSchema)]
pub struct TxidPath {
/// Bitcoin transaction id
#[schemars(example = &"4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b")]
pub txid: String,
}
+19
View File
@@ -0,0 +1,19 @@
use schemars::JsonSchema;
use serde::Serialize;
use crate::{TxIndex, Txid};
#[derive(Serialize, JsonSchema)]
/// Transaction Information
pub struct TransactionInfo {
#[schemars(
with = "String",
example = "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"
)]
pub txid: Txid,
#[schemars(example = TxIndex::new(0))]
pub index: TxIndex,
// #[serde(flatten)]
// #[schemars(with = "serde_json::Value")]
// pub tx: Transaction,
}
+1 -4
View File
@@ -83,11 +83,8 @@
- create map of all single words
- do some kind of score with that ?
- FEAT: discoverability
- catalog (tree/groups)
- search
- BUG: failover to `/api`
- ???: no HTML / redirects ?
- FEAT: support keyed version when fetching dataset: {date: value} / {date: [value]}
- ???: remove redirects ?
- FEAT: add support for https (rustls)
- _STORE_
- FEAT: save height and version in one file
@@ -4214,29 +4214,28 @@ export function createPartialOptions({ colors, brk }) {
{
name: "API",
url: () => "/api",
title: "Link to API documentation",
title: "API documentation",
},
{
name: "MCP",
url: () =>
"https://github.com/bitcoinresearchkit/brk/tree/main/crates/brk_mcp#brk-mcp",
title: "Link to MCP documentation",
"https://github.com/bitcoinresearchkit/brk/blob/main/crates/brk_mcp/README.md#brk_mcp",
title: "Model Context Protocol documentation",
},
{
name: "Crates",
url: () => "/crates",
title: "Link to BRK on crates.io",
name: "Crate",
url: () => "/crate",
title: "View on crates.io",
},
{
name: "Source",
url: () => "/github",
title: "Link to BRK's repository",
title: "Source code and issues",
},
{
name: "Changelog",
url: () =>
"https://github.com/bitcoinresearchkit/brk/blob/main/docs/CHANGELOG.md#changelog",
title: "BRK's changelog",
url: () => "/changelog",
title: "Release notes and changelog",
},
],
},
@@ -4246,37 +4245,37 @@ export function createPartialOptions({ colors, brk }) {
{
name: "Status",
url: () => "/status",
title: "Link to servers status",
title: "Service status and uptime",
},
{
name: "Self",
url: () => "/cli",
title: "Link to self-hosting documentation",
name: "Self-host",
url: () => "/install",
title: "Install and run yourself",
},
{
name: "As a service",
url: () => "/hosting",
title: "Link to hosting service",
name: "Service",
url: () => "/service",
title: "Hosted service offering",
},
],
},
{
name: "Social",
name: "Community",
tree: [
{
name: "Discord",
url: () => "/discord",
title: "Join the Discord server",
},
{
name: "GitHub",
url: () => "/github",
title: "Link to Github",
title: "Source code and issues",
},
{
name: "Nostr",
url: () => "/nostr",
title: "Link to BRK's nostr account",
},
{
name: "Discord",
url: () => "/discord",
title: "Link to BRK's discord server",
title: "Follow on Nostr",
},
],
},
+1 -3
View File
@@ -386,7 +386,7 @@ export function init({ colors, createChartElement, signals, resources }) {
}),
},
fees: {
percentage: signals.createSignal(/** @type {number | null} */ (0.25), {
percentage: signals.createSignal(/** @type {number | null} */ (1), {
save: {
...serdeOptNumber,
keyPrefix,
@@ -606,8 +606,6 @@ export function init({ colors, createChartElement, signals, resources }) {
resultsElement.append(p2);
const p3 = window.document.createElement("p");
resultsElement.append(p3);
const p4 = window.document.createElement("p");
resultsElement.append(p4);
const owner = signals.getOwner();
+382 -380
View File
@@ -4,348 +4,6 @@ import { tableElement } from "../core/elements";
import { serdeMetrics, serdeString, serdeUnit } from "../core/serde";
import { resetParams } from "../core/url";
/**
* @param {Object} args
* @param {Option} args.option
* @param {Signals} args.signals
* @param {BRK} args.brk
* @param {Resources} args.resources
*/
function createTable({ brk, signals, option, resources }) {
const indexToMetrics = createIndexToMetrics(metricToIndexes);
const serializedIndexes = createSerializedIndexes();
/** @type {SerializedIndex} */
const defaultSerializedIndex = "height";
const serializedIndex = /** @type {Signal<SerializedIndex>} */ (
signals.createSignal(
/** @type {SerializedIndex} */ (defaultSerializedIndex),
{
save: {
...serdeString,
keyPrefix: "table",
key: "index",
},
},
)
);
const index = signals.createMemo(() =>
serializedIndexToIndex(serializedIndex()),
);
const table = window.document.createElement("table");
const obj = {
element: table,
/** @type {VoidFunction | undefined} */
addRandomCol: undefined,
};
signals.createEffect(index, (index, prevIndex) => {
if (prevIndex !== undefined) {
resetParams(option);
}
const possibleMetrics = indexToMetrics[index];
const columns = signals.createSignal(/** @type {Metric[]} */ ([]), {
equals: false,
save: {
...serdeMetrics,
keyPrefix: `table-${serializedIndex()}`,
key: `columns`,
},
});
columns.set((l) => l.filter((id) => possibleMetrics.includes(id)));
signals.createEffect(columns, (columns) => {
console.log(columns);
});
table.innerHTML = "";
const thead = window.document.createElement("thead");
table.append(thead);
const trHead = window.document.createElement("tr");
thead.append(trHead);
const tbody = window.document.createElement("tbody");
table.append(tbody);
const rowElements = signals.createSignal(
/** @type {HTMLTableRowElement[]} */ ([]),
);
/**
* @param {Object} args
* @param {HTMLSelectElement} args.select
* @param {Unit} [args.unit]
* @param {(event: MouseEvent) => void} [args.onLeft]
* @param {(event: MouseEvent) => void} [args.onRight]
* @param {(event: MouseEvent) => void} [args.onRemove]
*/
function addThCol({ select, onLeft, onRight, onRemove, unit: _unit }) {
const th = window.document.createElement("th");
th.scope = "col";
trHead.append(th);
const div = window.document.createElement("div");
div.append(select);
// const top = window.document.createElement("div");
// div.append(top);
// top.append(select);
// top.append(
// createAnchorElement({
// href: "",
// blank: true,
// }),
// );
const bottom = window.document.createElement("div");
const unit = window.document.createElement("span");
if (_unit) {
unit.innerHTML = _unit;
}
const moveLeft = createButtonElement({
inside: "←",
title: "Move column to the left",
onClick: onLeft || (() => {}),
});
const moveRight = createButtonElement({
inside: "→",
title: "Move column to the right",
onClick: onRight || (() => {}),
});
const remove = createButtonElement({
inside: "×",
title: "Remove column",
onClick: onRemove || (() => {}),
});
bottom.append(unit);
bottom.append(moveLeft);
bottom.append(moveRight);
bottom.append(remove);
div.append(bottom);
th.append(div);
return {
element: th,
/**
* @param {Unit} _unit
*/
setUnit(_unit) {
unit.innerHTML = _unit;
},
};
}
addThCol({
...createSelect({
list: serializedIndexes,
signal: serializedIndex,
}),
unit: "index",
});
let from = 0;
let to = 0;
resources
.getOrCreate(index, serializedIndex())
.fetch()
.then((vec) => {
if (!vec) return;
from = /** @type {number} */ (vec[0]);
to = /** @type {number} */ (vec.at(-1)) + 1;
const trs = /** @type {HTMLTableRowElement[]} */ ([]);
for (let i = vec.length - 1; i >= 0; i--) {
const value = vec[i];
const tr = window.document.createElement("tr");
trs.push(tr);
tbody.append(tr);
const th = window.document.createElement("th");
th.innerHTML = serializeValue({
value,
unit: "index",
});
th.scope = "row";
tr.append(th);
}
rowElements.set(() => trs);
});
const owner = signals.getOwner();
/**
* @param {Metric} metric
* @param {number} [_colIndex]
*/
function addCol(metric, _colIndex = columns().length) {
signals.runWithOwner(owner, () => {
/** @type {VoidFunction | undefined} */
let dispose;
signals.createRoot((_dispose) => {
dispose = _dispose;
const metricOption = signals.createSignal({
name: metric,
value: metric,
});
const { select } = createSelect({
list: possibleMetrics.map((metric) => ({
name: metric,
value: metric,
})),
signal: metricOption,
});
signals.createEffect(metricOption, (metricOption) => {
select.style.width = `${21 + 7.25 * metricOption.name.length}px`;
});
if (_colIndex === columns().length) {
columns.set((l) => {
l.push(metric);
return l;
});
}
const colIndex = signals.createSignal(_colIndex);
/**
* @param {boolean} right
* @returns {(event: MouseEvent) => void}
*/
function createMoveColumnFunction(right) {
return () => {
const oldColIndex = colIndex();
const newColIndex = oldColIndex + (right ? 1 : -1);
const currentTh = /** @type {HTMLTableCellElement} */ (
trHead.childNodes[oldColIndex + 1]
);
const oterTh = /** @type {HTMLTableCellElement} */ (
trHead.childNodes[newColIndex + 1]
);
if (right) {
oterTh.after(currentTh);
} else {
oterTh.before(currentTh);
}
columns.set((l) => {
[l[oldColIndex], l[newColIndex]] = [
l[newColIndex],
l[oldColIndex],
];
return l;
});
const rows = rowElements();
for (let i = 0; i < rows.length; i++) {
const element = rows[i].childNodes[oldColIndex + 1];
const sibling = rows[i].childNodes[newColIndex + 1];
const temp = element.textContent;
element.textContent = sibling.textContent;
sibling.textContent = temp;
}
};
}
const th = addThCol({
select,
unit: serdeUnit.deserialize(metric),
onLeft: createMoveColumnFunction(false),
onRight: createMoveColumnFunction(true),
onRemove: () => {
const ci = colIndex();
trHead.childNodes[ci + 1].remove();
columns.set((l) => {
l.splice(ci, 1);
return l;
});
const rows = rowElements();
for (let i = 0; i < rows.length; i++) {
rows[i].childNodes[ci + 1].remove();
}
dispose?.();
},
});
signals.createEffect(columns, () => {
colIndex.set(Array.from(trHead.children).indexOf(th.element) - 1);
});
console.log(colIndex());
signals.createEffect(rowElements, (rowElements) => {
if (!rowElements.length) return;
for (let i = 0; i < rowElements.length; i++) {
const td = window.document.createElement("td");
rowElements[i].append(td);
}
signals.createEffect(
() => metricOption().name,
(metric, prevMetric) => {
const unit = serdeUnit.deserialize(metric);
th.setUnit(unit);
const vec = resources.getOrCreate(index, metric);
vec.fetch({ from, to });
const fetchedKey = resources.genFetchedKey({ from, to });
columns.set((l) => {
const i = l.indexOf(prevMetric ?? metric);
if (i === -1) {
l.push(metric);
} else {
l[i] = metric;
}
return l;
});
signals.createEffect(
() => vec.fetched().get(fetchedKey)?.vec(),
(vec) => {
if (!vec?.length) return;
const thIndex = colIndex() + 1;
for (let i = 0; i < rowElements.length; i++) {
const iRev = vec.length - 1 - i;
const value = vec[iRev];
// @ts-ignore
rowElements[i].childNodes[thIndex].innerHTML =
serializeValue({
value,
unit,
});
}
},
);
return () => metric;
},
);
});
});
signals.onCleanup(() => {
dispose?.();
});
});
}
columns().forEach((metric, colIndex) => addCol(metric, colIndex));
obj.addRandomCol = function () {
addCol(randomFromArray(possibleMetrics));
};
return () => index;
});
return obj;
}
/**
* @param {Object} args
* @param {Signals} args.signals
@@ -354,53 +12,397 @@ function createTable({ brk, signals, option, resources }) {
* @param {BRK} args.brk
*/
export function init({ signals, option, resources, brk }) {
const parent = tableElement;
const { headerElement } = createHeader("Table");
parent.append(headerElement);
tableElement.innerHTML = "wip, will hopefuly be back soon, sorry !";
const div = window.document.createElement("div");
parent.append(div);
// const parent = tableElement;
// const { headerElement } = createHeader("Table");
// parent.append(headerElement);
const table = createTable({
signals,
brk,
resources,
option,
});
div.append(table.element);
// const div = window.document.createElement("div");
// parent.append(div);
const span = window.document.createElement("span");
span.innerHTML = "Add column";
div.append(
createButtonElement({
onClick: () => {
table.addRandomCol?.();
},
inside: span,
title: "Click or tap to add a column to the table",
}),
);
// const table = createTable({
// signals,
// brk,
// resources,
// option,
// });
// div.append(table.element);
// const span = window.document.createElement("span");
// span.innerHTML = "Add column";
// div.append(
// createButtonElement({
// onClick: () => {
// table.addRandomCol?.();
// },
// inside: span,
// title: "Click or tap to add a column to the table",
// }),
// );
}
// /**
// * @param {Object} args
// * @param {Option} args.option
// * @param {Signals} args.signals
// * @param {BRK} args.brk
// * @param {Resources} args.resources
// */
// function createTable({ brk, signals, option, resources }) {
// const indexToMetrics = createIndexToMetrics(metricToIndexes);
// const serializedIndexes = createSerializedIndexes();
// /** @type {SerializedIndex} */
// const defaultSerializedIndex = "height";
// const serializedIndex = /** @type {Signal<SerializedIndex>} */ (
// signals.createSignal(
// /** @type {SerializedIndex} */ (defaultSerializedIndex),
// {
// save: {
// ...serdeString,
// keyPrefix: "table",
// key: "index",
// },
// },
// )
// );
// const index = signals.createMemo(() =>
// serializedIndexToIndex(serializedIndex()),
// );
// const table = window.document.createElement("table");
// const obj = {
// element: table,
// /** @type {VoidFunction | undefined} */
// addRandomCol: undefined,
// };
// signals.createEffect(index, (index, prevIndex) => {
// if (prevIndex !== undefined) {
// resetParams(option);
// }
// const possibleMetrics = indexToMetrics[index];
// const columns = signals.createSignal(/** @type {Metric[]} */ ([]), {
// equals: false,
// save: {
// ...serdeMetrics,
// keyPrefix: `table-${serializedIndex()}`,
// key: `columns`,
// },
// });
// columns.set((l) => l.filter((id) => possibleMetrics.includes(id)));
// signals.createEffect(columns, (columns) => {
// console.log(columns);
// });
// table.innerHTML = "";
// const thead = window.document.createElement("thead");
// table.append(thead);
// const trHead = window.document.createElement("tr");
// thead.append(trHead);
// const tbody = window.document.createElement("tbody");
// table.append(tbody);
// const rowElements = signals.createSignal(
// /** @type {HTMLTableRowElement[]} */ ([]),
// );
// /**
// * @param {Object} args
// * @param {HTMLSelectElement} args.select
// * @param {Unit} [args.unit]
// * @param {(event: MouseEvent) => void} [args.onLeft]
// * @param {(event: MouseEvent) => void} [args.onRight]
// * @param {(event: MouseEvent) => void} [args.onRemove]
// */
// function addThCol({ select, onLeft, onRight, onRemove, unit: _unit }) {
// const th = window.document.createElement("th");
// th.scope = "col";
// trHead.append(th);
// const div = window.document.createElement("div");
// div.append(select);
// // const top = window.document.createElement("div");
// // div.append(top);
// // top.append(select);
// // top.append(
// // createAnchorElement({
// // href: "",
// // blank: true,
// // }),
// // );
// const bottom = window.document.createElement("div");
// const unit = window.document.createElement("span");
// if (_unit) {
// unit.innerHTML = _unit;
// }
// const moveLeft = createButtonElement({
// inside: "←",
// title: "Move column to the left",
// onClick: onLeft || (() => {}),
// });
// const moveRight = createButtonElement({
// inside: "→",
// title: "Move column to the right",
// onClick: onRight || (() => {}),
// });
// const remove = createButtonElement({
// inside: "×",
// title: "Remove column",
// onClick: onRemove || (() => {}),
// });
// bottom.append(unit);
// bottom.append(moveLeft);
// bottom.append(moveRight);
// bottom.append(remove);
// div.append(bottom);
// th.append(div);
// return {
// element: th,
// /**
// * @param {Unit} _unit
// */
// setUnit(_unit) {
// unit.innerHTML = _unit;
// },
// };
// }
// addThCol({
// ...createSelect({
// list: serializedIndexes,
// signal: serializedIndex,
// }),
// unit: "index",
// });
// let from = 0;
// let to = 0;
// resources
// .getOrCreate(index, serializedIndex())
// .fetch()
// .then((vec) => {
// if (!vec) return;
// from = /** @type {number} */ (vec[0]);
// to = /** @type {number} */ (vec.at(-1)) + 1;
// const trs = /** @type {HTMLTableRowElement[]} */ ([]);
// for (let i = vec.length - 1; i >= 0; i--) {
// const value = vec[i];
// const tr = window.document.createElement("tr");
// trs.push(tr);
// tbody.append(tr);
// const th = window.document.createElement("th");
// th.innerHTML = serializeValue({
// value,
// unit: "index",
// });
// th.scope = "row";
// tr.append(th);
// }
// rowElements.set(() => trs);
// });
// const owner = signals.getOwner();
// /**
// * @param {Metric} metric
// * @param {number} [_colIndex]
// */
// function addCol(metric, _colIndex = columns().length) {
// signals.runWithOwner(owner, () => {
// /** @type {VoidFunction | undefined} */
// let dispose;
// signals.createRoot((_dispose) => {
// dispose = _dispose;
// const metricOption = signals.createSignal({
// name: metric,
// value: metric,
// });
// const { select } = createSelect({
// list: possibleMetrics.map((metric) => ({
// name: metric,
// value: metric,
// })),
// signal: metricOption,
// });
// signals.createEffect(metricOption, (metricOption) => {
// select.style.width = `${21 + 7.25 * metricOption.name.length}px`;
// });
// if (_colIndex === columns().length) {
// columns.set((l) => {
// l.push(metric);
// return l;
// });
// }
// const colIndex = signals.createSignal(_colIndex);
// /**
// * @param {boolean} right
// * @returns {(event: MouseEvent) => void}
// */
// function createMoveColumnFunction(right) {
// return () => {
// const oldColIndex = colIndex();
// const newColIndex = oldColIndex + (right ? 1 : -1);
// const currentTh = /** @type {HTMLTableCellElement} */ (
// trHead.childNodes[oldColIndex + 1]
// );
// const oterTh = /** @type {HTMLTableCellElement} */ (
// trHead.childNodes[newColIndex + 1]
// );
// if (right) {
// oterTh.after(currentTh);
// } else {
// oterTh.before(currentTh);
// }
// columns.set((l) => {
// [l[oldColIndex], l[newColIndex]] = [
// l[newColIndex],
// l[oldColIndex],
// ];
// return l;
// });
// const rows = rowElements();
// for (let i = 0; i < rows.length; i++) {
// const element = rows[i].childNodes[oldColIndex + 1];
// const sibling = rows[i].childNodes[newColIndex + 1];
// const temp = element.textContent;
// element.textContent = sibling.textContent;
// sibling.textContent = temp;
// }
// };
// }
// const th = addThCol({
// select,
// unit: serdeUnit.deserialize(metric),
// onLeft: createMoveColumnFunction(false),
// onRight: createMoveColumnFunction(true),
// onRemove: () => {
// const ci = colIndex();
// trHead.childNodes[ci + 1].remove();
// columns.set((l) => {
// l.splice(ci, 1);
// return l;
// });
// const rows = rowElements();
// for (let i = 0; i < rows.length; i++) {
// rows[i].childNodes[ci + 1].remove();
// }
// dispose?.();
// },
// });
// signals.createEffect(columns, () => {
// colIndex.set(Array.from(trHead.children).indexOf(th.element) - 1);
// });
// console.log(colIndex());
// signals.createEffect(rowElements, (rowElements) => {
// if (!rowElements.length) return;
// for (let i = 0; i < rowElements.length; i++) {
// const td = window.document.createElement("td");
// rowElements[i].append(td);
// }
// signals.createEffect(
// () => metricOption().name,
// (metric, prevMetric) => {
// const unit = serdeUnit.deserialize(metric);
// th.setUnit(unit);
// const vec = resources.getOrCreate(index, metric);
// vec.fetch({ from, to });
// const fetchedKey = resources.genFetchedKey({ from, to });
// columns.set((l) => {
// const i = l.indexOf(prevMetric ?? metric);
// if (i === -1) {
// l.push(metric);
// } else {
// l[i] = metric;
// }
// return l;
// });
// signals.createEffect(
// () => vec.fetched().get(fetchedKey)?.vec(),
// (vec) => {
// if (!vec?.length) return;
// const thIndex = colIndex() + 1;
// for (let i = 0; i < rowElements.length; i++) {
// const iRev = vec.length - 1 - i;
// const value = vec[iRev];
// // @ts-ignore
// rowElements[i].childNodes[thIndex].innerHTML =
// serializeValue({
// value,
// unit,
// });
// }
// },
// );
// return () => metric;
// },
// );
// });
// });
// signals.onCleanup(() => {
// dispose?.();
// });
// });
// }
// columns().forEach((metric, colIndex) => addCol(metric, colIndex));
// obj.addRandomCol = function () {
// addCol(randomFromArray(possibleMetrics));
// };
// return () => index;
// });
// return obj;
// }
/**
* @param {MetricToIndexes} metricToIndexes
*/
function createIndexToMetrics(metricToIndexes) {
const indexToMetrics = Object.entries(metricToIndexes).reduce(
(arr, [_id, indexes]) => {
const id = /** @type {Metric} */ (_id);
indexes.forEach((i) => {
arr[i] ??= [];
arr[i].push(id);
});
return arr;
},
/** @type {Metric[][]} */ (Array.from({ length: 24 })),
);
indexToMetrics.forEach((arr) => {
arr.sort();
});
return indexToMetrics;
// const indexToMetrics = Object.entries(metricToIndexes).reduce(
// (arr, [_id, indexes]) => {
// const id = /** @type {Metric} */ (_id);
// indexes.forEach((i) => {
// arr[i] ??= [];
// arr[i].push(id);
// });
// return arr;
// },
// /** @type {Metric[][]} */ (Array.from({ length: 24 })),
// );
// indexToMetrics.forEach((arr) => {
// arr.sort();
// });
// return indexToMetrics;
}
/**
+8 -8
View File
@@ -53,16 +53,16 @@ sw.addEventListener("fetch", (event) => {
if (
req.method !== "GET" ||
url.pathname.startsWith("/api") ||
url.pathname === "/mcp" ||
url.pathname === "/crates" ||
url.pathname === "/github" ||
url.pathname === "/status" ||
url.pathname === "/cli" ||
url.pathname === "/hosting" ||
url.pathname === "/nostr" ||
url.pathname === "/changelog" ||
url.pathname === "/crate" ||
url.pathname === "/discord" ||
url.pathname === "/nostr" ||
url.pathname === "/github" ||
url.pathname === "/health" ||
url.pathname === "/install" ||
url.pathname === "/mcp" ||
url.pathname === "/nostr" ||
url.pathname === "/service" ||
url.pathname === "/status" ||
url.pathname === "/version"
) {
return; // let the browser handle it