Squashed 'components/mycelium/' content from commit afb32e0

git-subtree-dir: components/mycelium
git-subtree-split: afb32e0cdb2d4cdd17f22a5693278068d061f08c
This commit is contained in:
2025-08-16 21:12:34 +02:00
commit 10025f9fa5
132 changed files with 50951 additions and 0 deletions

9
mycelium-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/dist/
/static/
/.dioxus/
# These are backup files generated by rustfmt
**/*.rs.bk

7232
mycelium-ui/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

35
mycelium-ui/Cargo.toml Normal file
View File

@@ -0,0 +1,35 @@
[package]
name = "mycelium-ui"
version = "0.6.1"
edition = "2021"
license-file = "../LICENSE"
readme = "../README.md"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dioxus = { version = "0.6.2", features = ["desktop", "router"] }
mycelium = { path = "../mycelium" }
mycelium-api = { path = "../mycelium-api" }
# Debug
tracing = "0.1.40"
dioxus-logger = "0.6.2"
reqwest = { version = "0.12.5", features = ["json"] }
serde_json = "1.0.120"
dioxus-sortable = "0.1.2"
manganis = "0.6.2"
dioxus-free-icons = { version = "0.9.0", features = [
"font-awesome-solid",
"font-awesome-brands",
"font-awesome-regular",
] }
human_bytes = { version = "0.4.3", features = ["fast"] }
tokio = "1.44.1"
dioxus-charts = "0.3.1"
futures-util = "0.3.31"
urlencoding = "2.1.3"
[features]
bundle = []

48
mycelium-ui/Dioxus.toml Normal file
View File

@@ -0,0 +1,48 @@
[application]
# App (Project) Name
name = "mycelium-ui"
# Dioxus App Default Platform
# desktop, web
default_platform = "desktop"
# `build` & `serve` dist path
out_dir = "dist"
# assets file folder
asset_dir = "assets"
[web.app]
# HTML title tag content
title = "mycelium-ui"
[web.watcher]
# when watcher trigger, regenerate the `index.html`
reload_html = true
# which files or dirs will be watcher monitoring
watch_path = ["src", "assets"]
# add fallback 404 page
index_on_404 = true
# include `assets` in web platform
[web.resource]
# CSS style file
style = [
"https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap",
]
# Javascript code file
script = []
[web.resource.dev]
# Javascript code file
# serve: [dev-server] only
script = []

54
mycelium-ui/README.md Normal file
View File

@@ -0,0 +1,54 @@
# Mycelium Network Dashboard
The Mycelium Network Dashboard is a GUI application built with Dioxus, a modern library for building
cross-platform applications using Rust. More information about Dioxus can be found [here](https://dioxuslabs.com/)
## Getting Started
To get started with the Mycelium Network Dashboard, you'll need to have the Dioxus CLI tool installed.
You can install it using the following command:
`cargo install dioxus-cli`
Before running the Mycelium Network Dashboard application, make sure that the `myceliumd` daemon is running on your system.
The myceliumd daemon is the background process that manages the Mycelium network connection
and provides the data that the dashboard application displays. For more information on setting up and
running `myceliumd`, please read [this](../README.md).
Once you have the Dioxus CLI installed, you can build and run the application in development mode using
the following command (in the `mycelium-ui` directory):
`dx serve`
This will start a development server and launch the application in a WebView.
## Bundling the application
To bundle the application, you can use:
`dx bundle --release --features bundle`
This will create a bundled version of the application in the `dist/bundle/` directory. The bundled
application can be distributed and run on various platforms, including Windows, MacOS and Linux. Dioxus
also offers support for mobile, but note that this has not been tested.
## Documentation
The Mycelium Network Dashboard application provides the following features:
- **Home**: Displays information about the node and allows to change address of the API server on which
the application should listen.
- **Peers**: Shows and overview of all the connected peers. Adding and removing peers can be done here.
- **Routes**: Provides information about the routing table and network routes
## Contributing
If you would like to contribute to the Mycelium Network Dashboard project, please follow the standard GitHub workflow:
1. Fork the repository
2. Create a new branch for your changes
3. Make your changes and commit them
4. Push your changes to your forked repository
5. Submit a pull request to the main repository

View File

@@ -0,0 +1,514 @@
@import url('https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap');
:root {
--primary-color: #3498db;
--secondary-color: #2c3e50;
--background-color: #ecf0f1;
--text-color: #34495e;
--border-color: #bdc3c7;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Lato', sans-serif;
background-color: var(--background-color);
color: var(--text-color);
}
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
header {
background-color: var(--primary-color);
color: white;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
position: fixed;
width: 100%;
z-index: 1000;
height: 60px;
}
header h1 {
font-size: 1rem;
font-weight: 700;
}
.node-info {
font-size: 0.9rem;
display: flex;
align-items: center;
}
.node-info span {
margin-right: 1rem;
}
.node-info .separator {
margin: 0 1rem;
}
.content-container {
display: flex;
padding-top: 60px;
min-height: calc(100vh - 60px);
}
.sidebar {
background-color: var(--secondary-color);
color: white;
width: 250px;
height: 100%;
position: fixed;
top: 60px;
left: 0;
transition: transform 0.3s ease-in-out;
z-index: 100;
}
.sidebar.collapsed {
transform: translateX(-250px);
}
.sidebar ul {
list-style-type: none;
padding: 1rem;
}
.sidebar li {
margin-bottom: 1rem;
}
.sidebar a {
color: white;
text-decoration: none;
font-size: 1.1rem;
transition: color 0.3s ease;
}
.sidebar a:hover {
color: var(--primary-color);
}
.main-content {
flex: 1;
padding: 2rem;
margin-left: 250px;
transition: margin-left 0.3s ease-in-out;
}
.main-content.expanded {
margin-left: 0;
}
.toggle-sidebar.collapsed {
left: 10px;
transform: translate(-10px) rotate(180deg);
transition: left 0.3s ease-in-out, transform: 0s;
}
.toggle-sidebar {
background-color: var(--secondary-color);
color: white;
border: none;
width: 40px;
height: 40px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 70px;
left: 250px;
transition: left 0.3s ease-in-out, transform 0.3s ease-in-out;
z-index: 200;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
background-color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
th {
background-color: var(--primary-color);
color: white;
font-weight: 700;
cursor: pointer;
transition: background-color 0.3s ease;
}
th:hover {
background-color: #2980b9;
}
.subnet-column { width: 25%; }
.next-hop-column { width: 35%; }
.metric-column { width: 20%; }
.seqno-column { width: 20%; }
.endpoint-column { width: 30%; }
.type-column { width: 15%; }
.connection-state-column { width: 20%; }
.tx-bytes-column { width: 17.5%; }
.rx-bytes-column { width: 17.5%; }
.pagination {
display: flex;
justify-content: center;
align-items: center;
margin-top: 1rem;
}
.pagination button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.5rem 1rem;
margin: 0 0.5rem;
cursor: pointer;
transition: background-color 0.3s ease;
}
.pagination button:hover:not(:disabled) {
background-color: #2980b9;
}
.pagination button:disabled {
background-color: var(--border-color);
cursor: not-allowed;
}
.pagination span {
margin: 0 0.5rem;
}
.peers-table, .selected-routes, .fallback-routes {
margin-top: 2rem;
}
h2 {
color: var(--secondary-color);
margin-bottom: 1rem;
}
.node-info h3 {
margin-bottom: 0.5rem;
font-size: 1.1rem;
font-weight: 400;
}
/* Home component */
.home-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.home-container h2 {
color: var(--secondary-color);
margin-bottom: 1.5rem;
}
.home-container p {
margin-bottom: 1rem;
}
.bold {
font-weight: 700;
}
/* API server */
.server-input {
display: flex;
margin-bottom: 1rem;
}
.server-input input {
flex-grow: 1;
padding: 0.5rem;
font-size: 1rem;
border: 1px solid var(--border-color);
border-radius: 4px 0 0 4px;
}
.server-input button {
padding: 0.5rem 1rem;
font-size: 1rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
transition: background-color 0.3s ease;
}
.server-input button:hover {
background-color: #2980b9;
}
.error {
color: #e74c3c;
margin-bottom: 1rem;
}
.warning {
color: #f39c12;
margin-bottom: 1rem;
}
/* Searching and adding */
.search-and-add-container {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
flex-wrap: wrap;
}
/* Searching */
.search-container {
flex: 0 0 60%;
display: flex;
margin-right: 1rem;
margin-bottom: 0.5rem;
}
.search-container input {
flex-grow: 1;
padding: 0.5rem;
font-size: 1rem;
border: 1px solid var(--border-color);
border-radius: 4px 0 0 4px;
min-width: 200px;
height: 40px;
}
.search-container select {
padding: 0.5rem;
font-size: 1rem;
border: 1px solid var(--border-color);
border-left: none;
border-radius: 0 4px 4px 0;
background-color: white;
min-width: 120px;
height: 40px;
}
/* Add peer button */
.add-peer-container {
flex: 0 0 35%;
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.add-peer-input-button {
display: flex;
width: 100%;
}
.add-peer-container button {
padding: 0.5rem 1rem;
font-size: 1rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s ease;
white-space: nowrap;
height: 40px;
}
.add-peer-container button:hover {
background-color: #2980b9
}
.add-peer-error {
color: #e74c3c !important;
font-size: 0.9rem;
margin-top: 0.5rem;
width: 100%;
}
.expanded-add-peer-container {
flex-grow: 1;
display: flex;
margin-right: 0.5rem;
}
.expanded-add-peer-container input {
flex-grow: 1;
padding: 0.5rem;
font-size: 1rem;
border: 1px solid var(--border-color);
border-radius: 4px;
min-width: 150px;
height: 40px;
}
/* Refresh button */
.refresh-button {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
cursor: pointer;
transition: background-color 0.3s ease;
border-radius: 4px;
font-size: 1rem;
}
.refresh-button:hover {
background-color: #2980b9;
}
.refresh-button svg {
margin-right: 0.5rem;
}
/* Expandable row styles */
.expanded-row {
background-color: #f8f9fa;
}
.graph-container {
width: calc(50% - 1rem);
margin-bottom: 2rem;
}
.graph-title {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 1rem;
color: var(--secondary-color);
text-align: center;
}
.expanded-content {
padding: 2rem;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
}
.expanded-content p {
margin: 0;
font-size: 0.5rem;
}
/* Style for both Tx and Rx charts */
.expanded-content svg {
width: 100%;
height: auto;
margin-bottom: 1rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
}
/* Style for chart lines */
.expanded-content path {
stroke-width: 2;
}
/* Style for Tx bytes chart */
.graph-container:nth-child(1) path {
stroke: #3498db;
}
/* Style for Rx bytes chart */
.graph-container:nth-child(2) path {
stroke: #2ecc71;
}
.button-container {
width: 100%;
display: flex;
justify-content: space-between;
margin-top: 1rem;
}
.close-button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.75rem 1.5rem;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.3s ease;
border-radius: 4px;
margin-top: 1rem;
}
.remove-button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.75rem 1.5rem;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.3s ease;
border-radius: 4px;
margin-top: 1rem;
}
.remove-button:hover {
background-color: #c0392b;
}
/* Make the table rows clickable */
tbody tr {
cursor: pointer;
}
tbody tr:hover {
background-color: #f1f3f5;
}
/* Responsive design for smaller screens */
@media (max-width: 768px) {
.expanded-content {
flex-direction: column;
align-items: center;
}
.graph-container {
width: 100%;
max-width: 100%;
}
}

98
mycelium-ui/src/api.rs Normal file
View 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(())
}

View File

@@ -0,0 +1,4 @@
pub mod home;
pub mod layout;
pub mod peers;
pub mod routes;

View 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)
}
}
}

View 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,
}
}
}
}

View 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 {}

View 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
mycelium-ui/src/main.rs Normal file
View 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,
}