Merge commit '10025f9fa5503865918cbae2af5366afe7fd7c54' as 'components/mycelium'
This commit is contained in:
98
components/mycelium/mycelium-ui/src/api.rs
Normal file
98
components/mycelium/mycelium-ui/src/api.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use mycelium::endpoint::Endpoint;
|
||||
use mycelium_api::AddPeer;
|
||||
use std::net::SocketAddr;
|
||||
use urlencoding::encode;
|
||||
|
||||
pub async fn get_peers(
|
||||
server_addr: SocketAddr,
|
||||
) -> Result<Vec<mycelium::peer_manager::PeerStats>, reqwest::Error> {
|
||||
let request_url = format!("http://{server_addr}/api/v1/admin/peers");
|
||||
match reqwest::get(&request_url).await {
|
||||
Err(e) => Err(e),
|
||||
Ok(resp) => match resp.json::<Vec<mycelium::peer_manager::PeerStats>>().await {
|
||||
Err(e) => Err(e),
|
||||
Ok(peers) => Ok(peers),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_selected_routes(
|
||||
server_addr: SocketAddr,
|
||||
) -> Result<Vec<mycelium_api::Route>, reqwest::Error> {
|
||||
let request_url = format!("http://{server_addr}/api/v1/admin/routes/selected");
|
||||
match reqwest::get(&request_url).await {
|
||||
Err(e) => Err(e),
|
||||
Ok(resp) => match resp.json::<Vec<mycelium_api::Route>>().await {
|
||||
Err(e) => Err(e),
|
||||
Ok(selected_routes) => Ok(selected_routes),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_fallback_routes(
|
||||
server_addr: SocketAddr,
|
||||
) -> Result<Vec<mycelium_api::Route>, reqwest::Error> {
|
||||
let request_url = format!("http://{server_addr}/api/v1/admin/routes/fallback");
|
||||
match reqwest::get(&request_url).await {
|
||||
Err(e) => Err(e),
|
||||
Ok(resp) => match resp.json::<Vec<mycelium_api::Route>>().await {
|
||||
Err(e) => Err(e),
|
||||
Ok(selected_routes) => Ok(selected_routes),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_node_info(server_addr: SocketAddr) -> Result<mycelium_api::Info, reqwest::Error> {
|
||||
let request_url = format!("http://{server_addr}/api/v1/admin");
|
||||
match reqwest::get(&request_url).await {
|
||||
Err(e) => Err(e),
|
||||
Ok(resp) => match resp.json::<mycelium_api::Info>().await {
|
||||
Err(e) => Err(e),
|
||||
Ok(node_info) => Ok(node_info),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_peer(
|
||||
server_addr: SocketAddr,
|
||||
peer_endpoint: Endpoint,
|
||||
) -> Result<(), reqwest::Error> {
|
||||
let full_endpoint = format!(
|
||||
"{}://{}",
|
||||
peer_endpoint.proto().to_string().to_lowercase(),
|
||||
peer_endpoint.address()
|
||||
);
|
||||
let encoded_full_endpoint = encode(&full_endpoint);
|
||||
let request_url = format!(
|
||||
"http://{}/api/v1/admin/peers/{}",
|
||||
server_addr, encoded_full_endpoint
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.delete(request_url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_peer(
|
||||
server_addr: SocketAddr,
|
||||
peer_endpoint: String,
|
||||
) -> Result<(), reqwest::Error> {
|
||||
println!("adding peer: {peer_endpoint}");
|
||||
let client = reqwest::Client::new();
|
||||
let request_url = format!("http://{server_addr}/api/v1/admin/peers");
|
||||
client
|
||||
.post(request_url)
|
||||
.json(&AddPeer {
|
||||
endpoint: peer_endpoint,
|
||||
})
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
4
components/mycelium/mycelium-ui/src/components.rs
Normal file
4
components/mycelium/mycelium-ui/src/components.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod home;
|
||||
pub mod layout;
|
||||
pub mod peers;
|
||||
pub mod routes;
|
||||
68
components/mycelium/mycelium-ui/src/components/home.rs
Normal file
68
components/mycelium/mycelium-ui/src/components/home.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use crate::api;
|
||||
use crate::{ServerAddress, ServerConnected};
|
||||
use dioxus::prelude::*;
|
||||
use std::net::SocketAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[component]
|
||||
pub fn Home() -> Element {
|
||||
let mut server_addr = use_context::<Signal<ServerAddress>>();
|
||||
let mut new_address = use_signal(|| server_addr.read().0.to_string());
|
||||
let mut node_info = use_resource(fetch_node_info);
|
||||
|
||||
let try_connect = move |_| {
|
||||
if let Ok(addr) = SocketAddr::from_str(&new_address.read()) {
|
||||
server_addr.write().0 = addr;
|
||||
node_info.restart();
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "home-container",
|
||||
h2 { "Node information" }
|
||||
div { class: "server-input",
|
||||
input {
|
||||
placeholder: "Server address (e.g. 127.0.0.1:8989)",
|
||||
value: "{new_address}",
|
||||
oninput: move |evt| new_address.set(evt.value().clone()),
|
||||
}
|
||||
button { onclick: try_connect, "Connect" }
|
||||
}
|
||||
{match node_info.read().as_ref() {
|
||||
Some(Ok(info)) => rsx! {
|
||||
p {
|
||||
"Node subnet: ",
|
||||
span { class: "bold", "{info.node_subnet}" }
|
||||
}
|
||||
p {
|
||||
"Node public key: ",
|
||||
span { class: "bold", "{info.node_pubkey}" }
|
||||
}
|
||||
},
|
||||
Some(Err(e)) => rsx! {
|
||||
p { class: "error", "Error: {e}" }
|
||||
},
|
||||
None => rsx! {
|
||||
p { "Enter a server address and click 'Connect' to fetch node information." }
|
||||
}
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_node_info() -> Result<mycelium_api::Info, reqwest::Error> {
|
||||
let server_addr = use_context::<Signal<ServerAddress>>();
|
||||
let mut server_connected = use_context::<Signal<ServerConnected>>();
|
||||
let address = server_addr.read().0;
|
||||
|
||||
match api::get_node_info(address).await {
|
||||
Ok(info) => {
|
||||
server_connected.write().0 = true;
|
||||
Ok(info)
|
||||
}
|
||||
Err(e) => {
|
||||
server_connected.write().0 = false;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
65
components/mycelium/mycelium-ui/src/components/layout.rs
Normal file
65
components/mycelium/mycelium-ui/src/components/layout.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use crate::{api, Route, ServerAddress};
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::{icons::fa_solid_icons::FaChevronLeft, Icon};
|
||||
|
||||
#[component]
|
||||
pub fn Layout() -> Element {
|
||||
let sidebar_collapsed = use_signal(|| false);
|
||||
|
||||
rsx! {
|
||||
div { class: "app-container",
|
||||
Header {}
|
||||
div { class: "content-container",
|
||||
Sidebar { collapsed: sidebar_collapsed }
|
||||
main { class: if *sidebar_collapsed.read() { "main-content expanded" } else { "main-content" },
|
||||
Outlet::<Route> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Header() -> Element {
|
||||
let server_addr = use_context::<Signal<ServerAddress>>();
|
||||
let fetched_node_info = use_resource(move || api::get_node_info(server_addr.read().0));
|
||||
|
||||
rsx! {
|
||||
header {
|
||||
h1 { "Mycelium Network Dashboard" }
|
||||
div { class: "node-info",
|
||||
{ match &*fetched_node_info.read_unchecked() {
|
||||
Some(Ok(info)) => rsx! {
|
||||
span { "Subnet: {info.node_subnet}" }
|
||||
span { class: "separator", "|" }
|
||||
span { "Public Key: {info.node_pubkey}" }
|
||||
},
|
||||
Some(Err(_)) => rsx! { span { "Error loading node info" } },
|
||||
None => rsx! { span { "Loading node info..." } },
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Sidebar(collapsed: Signal<bool>) -> Element {
|
||||
rsx! {
|
||||
nav { class: if *collapsed.read() { "sidebar collapsed" } else { "sidebar" },
|
||||
ul {
|
||||
li { Link { to: Route::Home {}, "Home" } }
|
||||
li { Link { to: Route::Peers {}, "Peers" } }
|
||||
li { Link { to: Route::Routes {}, "Routes" } }
|
||||
}
|
||||
}
|
||||
button { class: if *collapsed.read() { "toggle-sidebar collapsed" } else { "toggle-sidebar" },
|
||||
onclick: {
|
||||
let c = *collapsed.read();
|
||||
move |_| collapsed.set(!c)
|
||||
},
|
||||
Icon {
|
||||
icon: FaChevronLeft,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
521
components/mycelium/mycelium-ui/src/components/peers.rs
Normal file
521
components/mycelium/mycelium-ui/src/components/peers.rs
Normal file
@@ -0,0 +1,521 @@
|
||||
use crate::get_sort_indicator;
|
||||
use crate::{api, SearchState, ServerAddress, SortDirection, StopFetchingPeerSignal};
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_charts::LineChart;
|
||||
use human_bytes::human_bytes;
|
||||
use mycelium::{
|
||||
endpoint::Endpoint,
|
||||
peer_manager::{PeerStats, PeerType},
|
||||
};
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
str::FromStr,
|
||||
};
|
||||
use tracing::{error, info};
|
||||
|
||||
const REFRESH_RATE_MS: u64 = 500;
|
||||
const MAX_DATA_POINTS: usize = 8; // displays last 4 seconds
|
||||
|
||||
#[component]
|
||||
pub fn Peers() -> Element {
|
||||
let server_addr = use_context::<Signal<ServerAddress>>().read().0;
|
||||
let error = use_signal(|| None::<String>);
|
||||
let peer_data = use_signal(HashMap::<Endpoint, PeerStats>::new);
|
||||
|
||||
let _ = use_resource(move || async move {
|
||||
to_owned![server_addr, error, peer_data];
|
||||
loop {
|
||||
let stop_fetching_signal = use_context::<Signal<StopFetchingPeerSignal>>().read().0;
|
||||
if !stop_fetching_signal {
|
||||
match api::get_peers(server_addr).await {
|
||||
Ok(fetched_peers) => {
|
||||
peer_data.with_mut(|data| {
|
||||
// Collect the endpoint from the fetched peers
|
||||
let fetched_endpoints: HashSet<Endpoint> =
|
||||
fetched_peers.iter().map(|peer| peer.endpoint).collect();
|
||||
|
||||
// Remove peers that are no longer in the fetched data
|
||||
data.retain(|endpoint, _| fetched_endpoints.contains(endpoint));
|
||||
|
||||
// Insert or update the fetched peers
|
||||
for peer in fetched_peers {
|
||||
data.insert(peer.endpoint, peer);
|
||||
}
|
||||
});
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error fetching peers: {}", e);
|
||||
error.set(Some(format!(
|
||||
"An error has occurred while fetching peers: {}",
|
||||
e
|
||||
)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(REFRESH_RATE_MS)).await;
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
if let Some(err) = error.read().as_ref() {
|
||||
div { class: "error-message", "{err}" }
|
||||
} else {
|
||||
PeersTable { peer_data: peer_data }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PeersTable(peer_data: Signal<HashMap<Endpoint, PeerStats>>) -> Element {
|
||||
let mut current_page = use_signal(|| 0);
|
||||
let items_per_page = 20;
|
||||
let mut sort_column = use_signal(|| "Protocol".to_string());
|
||||
let mut sort_direction = use_signal(|| SortDirection::Ascending);
|
||||
let peers_len = peer_data.read().len();
|
||||
|
||||
// Pagination
|
||||
let mut change_page = move |delta: i32| {
|
||||
let cur_page = *current_page.read() as i32;
|
||||
current_page.set(
|
||||
(cur_page + delta)
|
||||
.max(0)
|
||||
.min((peers_len - 1) as i32 / items_per_page),
|
||||
);
|
||||
};
|
||||
|
||||
// Sorting
|
||||
let mut sort_peers_signal = move |column: String| {
|
||||
if column == *sort_column.read() {
|
||||
let new_sort_direction = match *sort_direction.read() {
|
||||
SortDirection::Ascending => SortDirection::Descending,
|
||||
SortDirection::Descending => SortDirection::Ascending,
|
||||
};
|
||||
sort_direction.set(new_sort_direction);
|
||||
} else {
|
||||
sort_column.set(column);
|
||||
sort_direction.set(SortDirection::Descending);
|
||||
}
|
||||
// When sorting, we should jump back to the first page
|
||||
current_page.set(0);
|
||||
};
|
||||
|
||||
let sorted_peers = use_memo(move || {
|
||||
let mut peers = peer_data.read().values().cloned().collect::<Vec<_>>();
|
||||
sort_peers(&mut peers, &sort_column.read(), &sort_direction.read());
|
||||
peers
|
||||
});
|
||||
|
||||
// Searching
|
||||
let mut search_state = use_signal(|| SearchState {
|
||||
query: String::new(),
|
||||
column: "Protocol".to_string(),
|
||||
});
|
||||
|
||||
let filtered_peers = use_memo(move || {
|
||||
let query = search_state.read().query.to_lowercase();
|
||||
let column = &search_state.read().column;
|
||||
let sorted_peers = sorted_peers.read();
|
||||
sorted_peers
|
||||
.iter()
|
||||
.filter(|peer| match column.as_str() {
|
||||
"Protocol" => peer
|
||||
.endpoint
|
||||
.proto()
|
||||
.to_string()
|
||||
.to_lowercase()
|
||||
.contains(&query),
|
||||
"Address" => peer
|
||||
.endpoint
|
||||
.address()
|
||||
.ip()
|
||||
.to_string()
|
||||
.to_lowercase()
|
||||
.contains(&query),
|
||||
"Port" => peer
|
||||
.endpoint
|
||||
.address()
|
||||
.port()
|
||||
.to_string()
|
||||
.to_lowercase()
|
||||
.contains(&query),
|
||||
"Type" => peer.pt.to_string().to_lowercase().contains(&query),
|
||||
"Connection State" => peer
|
||||
.connection_state
|
||||
.to_string()
|
||||
.to_lowercase()
|
||||
.contains(&query),
|
||||
"Tx bytes" => peer.tx_bytes.to_string().to_lowercase().contains(&query),
|
||||
"Rx bytes" => peer.rx_bytes.to_string().to_lowercase().contains(&query),
|
||||
_ => false,
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<PeerStats>>()
|
||||
});
|
||||
|
||||
let peers_len = filtered_peers.read().len();
|
||||
let start = current_page * items_per_page;
|
||||
let end = (start + items_per_page).min(peers_len as i32);
|
||||
let current_peers = filtered_peers.read()[start as usize..end as usize].to_vec();
|
||||
|
||||
// Expanding peer to show rx/tx bytes graphs
|
||||
let mut expanded_rows = use_signal(|| ExpandedRows(HashSet::new()));
|
||||
let mut toggle_row_expansion = move |peer_endpoint: String| {
|
||||
expanded_rows.with_mut(|rows| {
|
||||
if rows.0.contains(&peer_endpoint) {
|
||||
rows.0.remove(&peer_endpoint);
|
||||
} else {
|
||||
rows.0.insert(peer_endpoint);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let toggle_add_peer_input = use_signal(|| true); //TODO: fix UX for adding peer
|
||||
let mut add_peer_error = use_signal(|| None::<String>);
|
||||
let add_peer = move |peer_endpoint: String| {
|
||||
spawn(async move {
|
||||
let server_addr = use_context::<Signal<ServerAddress>>().read().0;
|
||||
// Check correct endpoint format and add peer
|
||||
match Endpoint::from_str(&peer_endpoint) {
|
||||
Ok(_) => {
|
||||
if let Err(e) = api::add_peer(server_addr, peer_endpoint.clone()).await {
|
||||
error!("Error adding peer: {e}");
|
||||
add_peer_error.set(Some(format!("Error adding peer: {}", e)));
|
||||
} else {
|
||||
info!("Succesfully added peer: {peer_endpoint}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Incorrect peer endpoint: {e}");
|
||||
add_peer_error.set(Some(format!("Incorrect peer endpoint: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
let mut new_peer_endpoint = use_signal(|| "".to_string());
|
||||
|
||||
rsx! {
|
||||
div { class: "peers-table",
|
||||
h2 { "Peers" }
|
||||
div { class: "search-and-add-container",
|
||||
div { class: "search-container",
|
||||
input {
|
||||
placeholder: "Search...",
|
||||
value: "{search_state.read().query}",
|
||||
oninput: move |evt| search_state.write().query.clone_from(&evt.value()),
|
||||
}
|
||||
select {
|
||||
value: "{search_state.read().column}",
|
||||
onchange: move |evt| search_state.write().column.clone_from(&evt.value()),
|
||||
option { value: "Protocol", "Protocol" }
|
||||
option { value: "Address", "Address" }
|
||||
option { value: "Port", "Port" }
|
||||
option { value: "Type", "Type" }
|
||||
option { value: "Connection State", "Connection State" }
|
||||
option { value: "Tx bytes", "Tx bytes" }
|
||||
option { value: "Rx bytes", "Rx bytes" }
|
||||
}
|
||||
}
|
||||
div { class: "add-peer-container",
|
||||
div { class: "add-peer-input-button",
|
||||
if *toggle_add_peer_input.read() {
|
||||
div { class: "expanded-add-peer-container",
|
||||
input {
|
||||
placeholder: "tcp://ipaddr:port",
|
||||
oninput: move |evt| new_peer_endpoint.set(evt.value())
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
onclick: move |_| add_peer(new_peer_endpoint.read().to_string()),
|
||||
"Add peer"
|
||||
}
|
||||
}
|
||||
if let Some(error) = add_peer_error.read().as_ref() {
|
||||
div { class: "add-peer-error", "{error}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "table-container",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { class: "protocol-column",
|
||||
onclick: move |_| sort_peers_signal("Protocol".to_string()),
|
||||
"Protocol {get_sort_indicator(sort_column, sort_direction, \"Protocol\".to_string())}"
|
||||
}
|
||||
th { class: "address-column",
|
||||
onclick: move |_| sort_peers_signal("Address".to_string()),
|
||||
"Address {get_sort_indicator(sort_column, sort_direction, \"Address\".to_string())}"
|
||||
}
|
||||
th { class: "port-column",
|
||||
onclick: move |_| sort_peers_signal("Port".to_string()),
|
||||
"Port {get_sort_indicator(sort_column, sort_direction, \"Port\".to_string())}"
|
||||
}
|
||||
th { class: "type-column",
|
||||
onclick: move |_| sort_peers_signal("Type".to_string()),
|
||||
"Type {get_sort_indicator(sort_column, sort_direction, \"Type\".to_string())}"
|
||||
}
|
||||
th { class: "connection-state-column",
|
||||
onclick: move |_| sort_peers_signal("Connection State".to_string()),
|
||||
"Connection State {get_sort_indicator(sort_column, sort_direction, \"Connection State\".to_string())}"
|
||||
}
|
||||
th { class: "tx-bytes-column",
|
||||
onclick: move |_| sort_peers_signal("Tx bytes".to_string()),
|
||||
"Tx bytes {get_sort_indicator(sort_column, sort_direction, \"Tx bytes\".to_string())}"
|
||||
}
|
||||
th { class: "rx-bytes-column",
|
||||
onclick: move |_| sort_peers_signal("Rx bytes".to_string()),
|
||||
"Rx bytes {get_sort_indicator(sort_column, sort_direction, \"Rx bytes\".to_string())}"
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for peer in current_peers.into_iter() {
|
||||
tr {
|
||||
onclick: move |_| toggle_row_expansion(peer.endpoint.to_string()),
|
||||
td { class: "protocol-column", "{peer.endpoint.proto()}" }
|
||||
td { class: "address-column", "{peer.endpoint.address().ip()}" }
|
||||
td { class: "port-column", "{peer.endpoint.address().port()}" }
|
||||
td { class: "type-column", "{peer.pt}" }
|
||||
td { class: "connection-state-column", "{peer.connection_state}" }
|
||||
td { class: "tx-bytes-column", "{human_bytes(peer.tx_bytes as f64)}" }
|
||||
td { class: "rx-bytes-column", "{human_bytes(peer.rx_bytes as f64)}" }
|
||||
}
|
||||
{
|
||||
let peer_expanded = expanded_rows.read().0.contains(&peer.endpoint.to_string());
|
||||
if peer_expanded {
|
||||
rsx! {
|
||||
ExpandedPeerRow {
|
||||
peer_endpoint: peer.endpoint,
|
||||
peer_data: peer_data,
|
||||
on_close: move |_| toggle_row_expansion(peer.endpoint.to_string()),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rsx! {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: *current_page.read() == 0,
|
||||
onclick: move |_| change_page(-1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page + 1}" }
|
||||
button {
|
||||
disabled: (current_page + 1) * items_per_page >= peers_len as i32,
|
||||
onclick: move |_| change_page(1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BandwidthData {
|
||||
tx_bytes: u64,
|
||||
rx_bytes: u64,
|
||||
timestamp: tokio::time::Duration,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ExpandedPeerRow(
|
||||
peer_endpoint: Endpoint,
|
||||
peer_data: Signal<HashMap<Endpoint, PeerStats>>,
|
||||
on_close: EventHandler<()>,
|
||||
) -> Element {
|
||||
let bandwidth_data = use_signal(VecDeque::<BandwidthData>::new);
|
||||
let start_time = use_signal(tokio::time::Instant::now);
|
||||
|
||||
use_future(move || {
|
||||
to_owned![bandwidth_data, start_time, peer_data, peer_endpoint];
|
||||
async move {
|
||||
let mut last_tx = 0;
|
||||
let mut last_rx = 0;
|
||||
if let Some(peer_stats) = peer_data.read().get(&peer_endpoint) {
|
||||
last_tx = peer_stats.tx_bytes;
|
||||
last_rx = peer_stats.rx_bytes;
|
||||
}
|
||||
|
||||
loop {
|
||||
let current_time = tokio::time::Instant::now();
|
||||
let elapsed_time = current_time.duration_since(*start_time.read());
|
||||
|
||||
if let Some(peer_stats) = peer_data.read().get(&peer_endpoint) {
|
||||
let tx_rate =
|
||||
(peer_stats.tx_bytes - last_tx) as f64 / (REFRESH_RATE_MS as f64 / 1000.0);
|
||||
let rx_rate =
|
||||
(peer_stats.rx_bytes - last_rx) as f64 / (REFRESH_RATE_MS as f64 / 1000.0);
|
||||
|
||||
bandwidth_data.with_mut(|data| {
|
||||
let new_data = BandwidthData {
|
||||
tx_bytes: tx_rate as u64,
|
||||
rx_bytes: rx_rate as u64,
|
||||
timestamp: elapsed_time,
|
||||
};
|
||||
data.push_back(new_data);
|
||||
|
||||
if data.len() > MAX_DATA_POINTS {
|
||||
data.pop_front();
|
||||
}
|
||||
});
|
||||
|
||||
last_tx = peer_stats.tx_bytes;
|
||||
last_rx = peer_stats.rx_bytes;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(REFRESH_RATE_MS)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let tx_data = use_memo(move || {
|
||||
bandwidth_data
|
||||
.read()
|
||||
.iter()
|
||||
.map(|d| d.tx_bytes as f32)
|
||||
.collect::<Vec<f32>>()
|
||||
});
|
||||
|
||||
let rx_data = use_memo(move || {
|
||||
bandwidth_data
|
||||
.read()
|
||||
.iter()
|
||||
.map(|d| d.rx_bytes as f32)
|
||||
.collect::<Vec<f32>>()
|
||||
});
|
||||
let labels = bandwidth_data
|
||||
.read()
|
||||
.iter()
|
||||
.map(|d| format!("{:.1}", d.timestamp.as_secs_f64()))
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let remove_peer = move |_| {
|
||||
spawn(async move {
|
||||
println!("Removing peer: {}", peer_endpoint);
|
||||
let server_addr = use_context::<Signal<ServerAddress>>().read().0;
|
||||
match api::remove_peer(server_addr, peer_endpoint).await {
|
||||
Ok(_) => on_close.call(()),
|
||||
Err(e) => eprintln!("Error removing peer: {e}"),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
rsx! {
|
||||
tr { class: "expanded-row",
|
||||
td { colspan: "7",
|
||||
div { class: "expanded-content",
|
||||
div { class: "graph-container",
|
||||
// Tx chart
|
||||
div { class: "graph-title", "Tx Bytes/s" }
|
||||
LineChart {
|
||||
show_grid: false,
|
||||
show_dots: false,
|
||||
padding_top: 80,
|
||||
padding_left: 100,
|
||||
padding_right: 80,
|
||||
padding_bottom: 80,
|
||||
label_interpolation: (|v| human_bytes(v as f64).to_string()) as fn(f32)-> String,
|
||||
series: vec![tx_data.read().to_vec()],
|
||||
labels: labels.clone(),
|
||||
series_labels: vec!["Tx Bytes/s".into()],
|
||||
}
|
||||
}
|
||||
div { class: "graph-container",
|
||||
// Rx chart
|
||||
div { class: "graph-title", "Rx Bytes/s" }
|
||||
LineChart {
|
||||
show_grid: false,
|
||||
show_dots: false,
|
||||
padding_top: 80,
|
||||
padding_left: 100,
|
||||
padding_right: 80,
|
||||
padding_bottom: 80,
|
||||
label_interpolation: (|v| human_bytes(v as f64).to_string()) as fn(f32)-> String,
|
||||
series: vec![rx_data.read().clone()],
|
||||
labels: labels.clone(),
|
||||
series_labels: vec!["Rx Bytes/s".into()],
|
||||
}
|
||||
}
|
||||
div { class: "button-container",
|
||||
button { class: "close-button",
|
||||
onclick: move |_| on_close.call(()),
|
||||
"Close"
|
||||
}
|
||||
button { class: "remove-button",
|
||||
onclick: remove_peer,
|
||||
"Remove peer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct ExpandedRows(HashSet<String>);
|
||||
|
||||
fn sort_peers(
|
||||
peers: &mut [mycelium::peer_manager::PeerStats],
|
||||
column: &str,
|
||||
direction: &SortDirection,
|
||||
) {
|
||||
peers.sort_by(|a, b| {
|
||||
let cmp = match column {
|
||||
"Protocol" => a.endpoint.proto().cmp(&b.endpoint.proto()),
|
||||
"Address" => a.endpoint.address().ip().cmp(&b.endpoint.address().ip()),
|
||||
"Port" => a
|
||||
.endpoint
|
||||
.address()
|
||||
.port()
|
||||
.cmp(&b.endpoint.address().port()),
|
||||
"Type" => PeerTypeWrapper(a.pt.clone()).cmp(&PeerTypeWrapper(b.pt.clone())),
|
||||
"Connection State" => a.connection_state.cmp(&b.connection_state),
|
||||
"Tx bytes" => a.tx_bytes.cmp(&b.tx_bytes),
|
||||
"Rx bytes" => a.rx_bytes.cmp(&b.rx_bytes),
|
||||
_ => Ordering::Equal,
|
||||
};
|
||||
match direction {
|
||||
SortDirection::Ascending => cmp,
|
||||
SortDirection::Descending => cmp.reverse(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub struct PeerTypeWrapper(pub mycelium::peer_manager::PeerType);
|
||||
impl Ord for PeerTypeWrapper {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
match (&self.0, &other.0) {
|
||||
(PeerType::Static, PeerType::Static) => Ordering::Equal,
|
||||
(PeerType::Static, _) => Ordering::Less,
|
||||
(PeerType::LinkLocalDiscovery, PeerType::Static) => Ordering::Greater,
|
||||
(PeerType::LinkLocalDiscovery, PeerType::LinkLocalDiscovery) => Ordering::Equal,
|
||||
(PeerType::LinkLocalDiscovery, PeerType::Inbound) => Ordering::Less,
|
||||
(PeerType::Inbound, PeerType::Inbound) => Ordering::Equal,
|
||||
(PeerType::Inbound, _) => Ordering::Greater,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for PeerTypeWrapper {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for PeerTypeWrapper {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0 == other.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for PeerTypeWrapper {}
|
||||
193
components/mycelium/mycelium-ui/src/components/routes.rs
Normal file
193
components/mycelium/mycelium-ui/src/components/routes.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::api;
|
||||
use crate::{get_sort_indicator, SearchState, ServerAddress, SortDirection};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Routes() -> Element {
|
||||
rsx! {
|
||||
SelectedRoutesTable {}
|
||||
FallbackRoutesTable {}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectedRoutesTable() -> Element {
|
||||
let server_addr = use_context::<Signal<ServerAddress>>();
|
||||
let fetched_selected_routes =
|
||||
use_resource(move || api::get_selected_routes(server_addr.read().0));
|
||||
|
||||
match &*fetched_selected_routes.read_unchecked() {
|
||||
Some(Ok(routes)) => {
|
||||
rsx! { RoutesTable { routes: routes.clone(), table_name: "Selected"} }
|
||||
}
|
||||
Some(Err(e)) => rsx! { div { "An error has occurred while fetching selected routes: {e}" }},
|
||||
None => rsx! { div { "Loading selected routes..." }},
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn FallbackRoutesTable() -> Element {
|
||||
let server_addr = use_context::<Signal<ServerAddress>>();
|
||||
let fetched_fallback_routes =
|
||||
use_resource(move || api::get_fallback_routes(server_addr.read().0));
|
||||
|
||||
match &*fetched_fallback_routes.read_unchecked() {
|
||||
Some(Ok(routes)) => {
|
||||
rsx! { RoutesTable { routes: routes.clone(), table_name: "Fallback"} }
|
||||
}
|
||||
Some(Err(e)) => rsx! { div { "An error has occurred while fetching fallback routes: {e}" }},
|
||||
None => rsx! { div { "Loading fallback routes..." }},
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn RoutesTable(routes: Vec<mycelium_api::Route>, table_name: String) -> Element {
|
||||
let mut current_page = use_signal(|| 0);
|
||||
let items_per_page = 10;
|
||||
let mut sort_column = use_signal(|| "Subnet".to_string());
|
||||
let mut sort_direction = use_signal(|| SortDirection::Descending);
|
||||
let routes_len = routes.len();
|
||||
|
||||
let mut change_page = move |delta: i32| {
|
||||
let cur_page = *current_page.read() as i32;
|
||||
current_page.set(
|
||||
(cur_page + delta)
|
||||
.max(0)
|
||||
.min((routes_len - 1) as i32 / items_per_page as i32) as usize,
|
||||
);
|
||||
};
|
||||
|
||||
let mut sort_routes_signal = move |column: String| {
|
||||
if column == *sort_column.read() {
|
||||
let new_sort_direction = match *sort_direction.read() {
|
||||
SortDirection::Ascending => SortDirection::Descending,
|
||||
SortDirection::Descending => SortDirection::Ascending,
|
||||
};
|
||||
sort_direction.set(new_sort_direction);
|
||||
} else {
|
||||
sort_column.set(column);
|
||||
sort_direction.set(SortDirection::Ascending);
|
||||
}
|
||||
current_page.set(0);
|
||||
};
|
||||
|
||||
let sorted_routes = use_memo(move || {
|
||||
let mut sorted = routes.clone();
|
||||
sort_routes(&mut sorted, &sort_column.read(), &sort_direction.read());
|
||||
sorted
|
||||
});
|
||||
|
||||
let mut search_state = use_signal(|| SearchState {
|
||||
query: String::new(),
|
||||
column: "Subnet".to_string(),
|
||||
});
|
||||
|
||||
let filtered_routes = use_memo(move || {
|
||||
let query = search_state.read().query.to_lowercase();
|
||||
let column = &search_state.read().column;
|
||||
sorted_routes
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|route| match column.as_str() {
|
||||
"Subnet" => route.subnet.to_string().to_lowercase().contains(&query),
|
||||
"Next-hop" => route.next_hop.to_string().to_lowercase().contains(&query),
|
||||
"Metric" => route.metric.to_string().to_lowercase().contains(&query),
|
||||
"Seqno" => route.seqno.to_string().to_lowercase().contains(&query),
|
||||
_ => false,
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let routes_len = filtered_routes.len();
|
||||
|
||||
let start = current_page * items_per_page;
|
||||
let end = (start + items_per_page).min(routes_len);
|
||||
let current_routes = &filtered_routes.read()[start..end];
|
||||
|
||||
rsx! {
|
||||
div { class: "{table_name.to_lowercase()}-routes",
|
||||
h2 { "{table_name} Routes" }
|
||||
div { class: "search-container",
|
||||
input {
|
||||
placeholder: "Search...",
|
||||
value: "{search_state.read().query}",
|
||||
oninput: move |evt| search_state.write().query.clone_from(&evt.value()),
|
||||
}
|
||||
select {
|
||||
value: "{search_state.read().column}",
|
||||
onchange: move |evt| search_state.write().column.clone_from(&evt.value()),
|
||||
option { value: "Subnet", "Subnet" }
|
||||
option { value: "Next-hop", "Next-hop" }
|
||||
option { value: "Metric", "Metric" }
|
||||
option { value: "Seqno", "Seqno" }
|
||||
}
|
||||
}
|
||||
div { class: "table-container",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { class: "subnet-column",
|
||||
onclick: move |_| sort_routes_signal("Subnet".to_string()),
|
||||
"Subnet {get_sort_indicator(sort_column, sort_direction, \"Subnet\".to_string())}"
|
||||
}
|
||||
th { class: "next-hop-column",
|
||||
onclick: move |_| sort_routes_signal("Next-hop".to_string()),
|
||||
"Next-hop {get_sort_indicator(sort_column, sort_direction, \"Next-hop\".to_string())}"
|
||||
}
|
||||
th { class: "metric-column",
|
||||
onclick: move |_| sort_routes_signal("Metric".to_string()),
|
||||
"Metric {get_sort_indicator(sort_column, sort_direction, \"Metric\".to_string())}"
|
||||
}
|
||||
th { class: "seqno_column",
|
||||
onclick: move |_| sort_routes_signal("Seqno".to_string()),
|
||||
"Seqno {get_sort_indicator(sort_column, sort_direction, \"Seqno\".to_string())}"
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for route in current_routes {
|
||||
tr {
|
||||
td { class: "subnet-column", "{route.subnet}" }
|
||||
td { class: "next-hop-column", "{route.next_hop}" }
|
||||
td { class: "metric-column", "{route.metric}" }
|
||||
td { class: "seqno-column", "{route.seqno}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: *current_page.read() == 0,
|
||||
onclick: move |_| change_page(-1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page + 1}" }
|
||||
button {
|
||||
disabled: (current_page + 1) * items_per_page >= routes_len,
|
||||
onclick: move |_| change_page(1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_routes(routes: &mut [mycelium_api::Route], column: &str, direction: &SortDirection) {
|
||||
routes.sort_by(|a, b| {
|
||||
let cmp = match column {
|
||||
"Subnet" => a.subnet.cmp(&b.subnet),
|
||||
"Next-hop" => a.next_hop.cmp(&b.next_hop),
|
||||
"Metric" => a.metric.cmp(&b.metric),
|
||||
"Seqno" => a.seqno.cmp(&b.seqno),
|
||||
_ => Ordering::Equal,
|
||||
};
|
||||
match direction {
|
||||
SortDirection::Ascending => cmp,
|
||||
SortDirection::Descending => cmp.reverse(),
|
||||
}
|
||||
});
|
||||
}
|
||||
118
components/mycelium/mycelium-ui/src/main.rs
Normal file
118
components/mycelium/mycelium-ui/src/main.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
#![allow(non_snake_case)]
|
||||
// Disable terminal popup on Windows
|
||||
#![cfg_attr(feature = "bundle", windows_subsystem = "windows")]
|
||||
|
||||
mod api;
|
||||
mod components;
|
||||
|
||||
use components::home::Home;
|
||||
use components::peers::Peers;
|
||||
use components::routes::Routes;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use mycelium::{endpoint::Endpoint, peer_manager::PeerStats};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
};
|
||||
|
||||
const _: manganis::Asset = manganis::asset!("assets/styles.css");
|
||||
|
||||
const DEFAULT_SERVER_ADDR: SocketAddr =
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8989);
|
||||
|
||||
fn main() {
|
||||
// Init logger
|
||||
dioxus_logger::init(tracing::Level::INFO).expect("failed to init logger");
|
||||
|
||||
let config = dioxus::desktop::Config::new()
|
||||
.with_custom_head(r#"<link rel="stylesheet" href="styles.css">"#.to_string());
|
||||
LaunchBuilder::desktop().with_cfg(config).launch(App);
|
||||
// dioxus::launch(App);
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn App() -> Element {
|
||||
use_context_provider(|| Signal::new(ServerAddress(DEFAULT_SERVER_ADDR)));
|
||||
use_context_provider(|| Signal::new(ServerConnected(false)));
|
||||
use_context_provider(|| {
|
||||
Signal::new(PeerSignalMapping(
|
||||
HashMap::<Endpoint, Signal<PeerStats>>::new(),
|
||||
))
|
||||
});
|
||||
use_context_provider(|| Signal::new(StopFetchingPeerSignal(false)));
|
||||
|
||||
rsx! {
|
||||
Router::<Route> {
|
||||
config: || {
|
||||
RouterConfig::default().on_update(|state| {
|
||||
use_context::<Signal<StopFetchingPeerSignal>>().write().0 = state.current() != Route::Peers {};
|
||||
(state.current() == Route::Peers {}).then_some(NavigationTarget::Internal(Route::Peers {}))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Routable, Debug, PartialEq)]
|
||||
#[rustfmt::skip]
|
||||
pub enum Route {
|
||||
#[layout(components::layout::Layout)]
|
||||
#[route("/")]
|
||||
Home {},
|
||||
#[route("/peers")]
|
||||
Peers,
|
||||
#[route("/routes")]
|
||||
Routes,
|
||||
#[end_layout]
|
||||
#[route("/:..route")]
|
||||
PageNotFound { route: Vec<String> },
|
||||
}
|
||||
//
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct SearchState {
|
||||
query: String,
|
||||
column: String,
|
||||
}
|
||||
|
||||
// This signal is used to stop the loop that keeps fetching information about the peers when
|
||||
// looking at the peers table, e.g. when the user goes back to Home or Routes page.
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct StopFetchingPeerSignal(bool);
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct ServerAddress(SocketAddr);
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct ServerConnected(bool);
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct PeerSignalMapping(HashMap<Endpoint, Signal<PeerStats>>);
|
||||
|
||||
pub fn get_sort_indicator(
|
||||
sort_column: Signal<String>,
|
||||
sort_direction: Signal<SortDirection>,
|
||||
column: String,
|
||||
) -> String {
|
||||
if *sort_column.read() == column {
|
||||
match *sort_direction.read() {
|
||||
SortDirection::Ascending => " ↑".to_string(),
|
||||
SortDirection::Descending => " ↓".to_string(),
|
||||
}
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PageNotFound(route: Vec<String>) -> Element {
|
||||
rsx! {
|
||||
p { "Page not found"}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SortDirection {
|
||||
Ascending,
|
||||
Descending,
|
||||
}
|
||||
Reference in New Issue
Block a user