Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 245 additions & 8 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ unicode-width = "0.2"
p2poolv2_config = { git = "https://github.com/p2poolv2/p2poolv2", package = "p2poolv2_config" }
bitcoin = "0.32.5"
toml_edit = "0.22"
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }

[dev-dependencies]
insta = "1.44.3"
serial_test = "3"
tempfile = "3"
mockito = "1.7.2"
serde_json = "1.0.149"
5 changes: 5 additions & 0 deletions config/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[api]
host = "127.0.0.1"
port = 46884
auth_user = "p2pool"
auth_pass = "p2pool"
55 changes: 55 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
use crate::bitcoin_config::ConfigEntry as BitcoinEntry;
use crate::components::bitcoin_config_view::BitcoinConfigView;
use crate::components::file_explorer::FileExplorer;
use crate::components::p2pool_client::{ChainInfo, P2PoolClient};
use crate::components::p2pool_config_view::P2PoolConfigView;
use crate::components::settings_view::SettingsView;
use crate::settings::Settings;
use p2poolv2_config::Config as P2PoolConfig;
use std::path::PathBuf;
use tokio::sync::mpsc;

/// Sidebar items labels
pub const SIDEBAR_ITEMS: &[(&str, CurrentScreen)] = &[
Expand All @@ -31,6 +33,11 @@ pub const BITCOIN_STATUS_TABS: &[&str] = &["Chain Info", "System", "Logs", "Peer

pub const MAX_BITCOIN_STATUS_TAB: usize = BITCOIN_STATUS_TABS.len() - 1;

/// Tab labels for the P2Pool Status view
pub const P2POOL_STATUS_TABS: &[&str] = &["Chain Info"];

pub const MAX_P2POOL_STATUS_TAB: usize = P2POOL_STATUS_TABS.len() - 1;

#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum CurrentScreen {
Home,
Expand Down Expand Up @@ -96,17 +103,25 @@ pub struct App {
pub bitcoin_data: Vec<BitcoinEntry>,
pub bitcoin_status_tab: usize,
pub settings: Settings,
pub p2pool_client: P2PoolClient,
/// Cached value of the `HOME` environment variable, used for path display.
/// Populated once at startup to avoid repeated syscalls during rendering.
pub home_dir: String,
/// Cached result of `settings::config_dir()`, used to display the default
/// settings storage path without repeated env-var lookups during rendering.
pub config_dir: PathBuf,
pub p2pool_status_tab: usize,
pub chain_info: Option<ChainInfo>,
pub p2pool_chain_info_error: Option<String>,
// async channel to receive chain info updates from the background task that fetches it when the P2Pool Status screen is opened
pub chain_info_tx: mpsc::UnboundedSender<anyhow::Result<ChainInfo>>,
pub chain_info_rx: mpsc::UnboundedReceiver<anyhow::Result<ChainInfo>>,
}

impl App {
#[must_use]
pub fn new() -> App {
let (tx, rx) = mpsc::unbounded_channel();
App {
current_screen: CurrentScreen::Home,
sidebar_index: 0,
Expand All @@ -121,8 +136,37 @@ impl App {
bitcoin_data: Vec::new(),
bitcoin_status_tab: 0,
settings: Settings::default(),
p2pool_client: P2PoolClient::new(),
home_dir: std::env::var("HOME").unwrap_or_default(),
config_dir: crate::settings::config_dir().unwrap_or_default(),
p2pool_status_tab: 0,
chain_info: None,
p2pool_chain_info_error: None,
chain_info_tx: tx,
chain_info_rx: rx,
}
}

#[must_use]
pub fn new_with_client(client: P2PoolClient) -> App {
let mut app = App::new();
app.p2pool_client = client;
app
}

/// Non-blocking result handler
pub fn poll_chain_info(&mut self) {
while let Ok(result) = self.chain_info_rx.try_recv() {
match result {
Ok(info) => {
self.chain_info = Some(info);
self.p2pool_chain_info_error = None;
}
Err(e) => {
self.chain_info = None;
self.p2pool_chain_info_error = Some(e.to_string());
}
}
}
}

Expand All @@ -142,6 +186,17 @@ impl App {
}
if let Some(&(_, screen)) = SIDEBAR_ITEMS.get(self.sidebar_index) {
self.current_screen = screen;
if self.current_screen == CurrentScreen::P2PoolStatus {
let client = self.p2pool_client.clone();
let tx = self.chain_info_tx.clone();

if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let res = client.fetch_chain_info().await;
let _ = tx.send(res.map_err(anyhow::Error::from));
});
}
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod file_explorer;
pub mod home_view;
pub mod ln_config_view;
pub mod ln_status_view;
pub mod p2pool_client;
pub mod p2pool_config_view;
pub mod p2pool_status_view;
pub mod settings_view;
Expand Down
206 changes: 206 additions & 0 deletions src/components/p2pool_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// SPDX-FileCopyrightText: 2024 PDM Authors
//
// SPDX-License-Identifier: AGPL-3.0-or-later

use crate::config::load_api_config;
use reqwest::Client;
use serde::Deserialize;
use std::time::Duration;

const REQUEST_TIMEOUT_SECONDS: u64 = 10;

#[derive(Debug, Clone)]
pub struct P2PoolClient {
client: Client,
base_url: String,
auth_credentials: Option<(String, String)>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ChainInfo {
pub genesis_blockhash: Option<String>,
pub chain_tip_height: Option<u64>,
pub total_work: String,
pub chain_tip_blockhash: Option<String>,
}

fn build_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECONDS))
.build()
.expect("Failed to build reqwest client")
}

impl P2PoolClient {
pub fn new() -> Self {
let (base_url, auth_credentials) = load_api_config()
.map(|cfg| {
let credentials = cfg.auth_user.zip(cfg.auth_pass);
(cfg.base_url, credentials)
})
.unwrap_or_else(|_| ("http://localhost:46884".to_string(), None));

Self {
client: build_client(),
base_url,
auth_credentials,
}
}

pub fn with_base_url(base_url: impl Into<String>) -> Self {
Self {
client: build_client(),
base_url: base_url.into(),
auth_credentials: None,
}
}

pub fn with_client(client: Client, base_url: impl Into<String>) -> Self {
Self {
client,
base_url: base_url.into(),
auth_credentials: None,
}
}

pub fn with_auth(mut self, user: String, pass: String) -> Self {
self.auth_credentials = Some((user, pass));
self
}

pub async fn fetch_chain_info(&self) -> Result<ChainInfo, reqwest::Error> {
let url = format!("{}/chain_info", self.base_url);
let mut request = self.client.get(url);

if let Some((user, pass)) = &self.auth_credentials {
request = request.basic_auth(user, Some(pass));
}

let response = request.send().await?.error_for_status()?;
let data = response.json::<ChainInfo>().await?;

Ok(data)
}
}

impl Default for P2PoolClient {
fn default() -> Self {
Self::new()
}
}

#[cfg(test)]
mod tests {
use super::*;
use mockito::Server;
use serde_json::json;

#[tokio::test]
async fn test_fetch_chain_info_success() {
let mut server = Server::new_async().await;

let mock = server
.mock("GET", "/chain_info")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"genesis_blockhash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
"chain_tip_height": 850_000u64,
"total_work": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"chain_tip_blockhash": "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054"
})
.to_string(),
)
.create();

let client = P2PoolClient::with_base_url(server.url());
let result = client.fetch_chain_info().await.unwrap();

assert_eq!(result.chain_tip_height, Some(850_000));
assert_eq!(
result.total_work,
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
);
assert_eq!(
result.genesis_blockhash.unwrap(),
"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
);
assert_eq!(
result.chain_tip_blockhash.unwrap(),
"00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054"
);
mock.assert();
}

#[tokio::test]
async fn test_fetch_chain_info_sends_basic_auth() {
let mut server = Server::new_async().await;

let mock = server
.mock("GET", "/chain_info")
.match_header("authorization", "Basic dXNlcjpwYXNzd29yZA==")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!({ "total_work": "abc" }).to_string())
.create();

let client =
P2PoolClient::with_base_url(server.url()).with_auth("user".into(), "password".into());

client.fetch_chain_info().await.unwrap();
mock.assert();
}

#[tokio::test]
async fn test_fetch_chain_info_errors_on_http_500() {
let mut server = Server::new_async().await;

server.mock("GET", "/chain_info").with_status(500).create();

let client = P2PoolClient::with_base_url(server.url());
assert!(client.fetch_chain_info().await.is_err());
}

#[tokio::test]
async fn test_fetch_chain_info_returns_error_on_missing_required_field() {
let mut server = Server::new_async().await;

server
.mock("GET", "/chain_info")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!({ "chain_tip_height": 100 }).to_string())
.create();

let client = P2PoolClient::with_base_url(server.url());
assert!(client.fetch_chain_info().await.is_err());
}

#[tokio::test]
async fn test_with_client_can_be_injected_for_isolated_tests() {
let mut server = Server::new_async().await;

let mock = server
.mock("GET", "/chain_info")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"genesis_blockhash": null,
"chain_tip_height": 1,
"total_work": "abc",
"chain_tip_blockhash": null
})
.to_string(),
)
.create();

let client = P2PoolClient::with_client(build_client(), server.url());
let result = client.fetch_chain_info().await.unwrap();

assert_eq!(result.chain_tip_height, Some(1));
assert_eq!(result.total_work, "abc");
mock.assert();
}
}
Loading
Loading