Files
mycelium/mycelium-ui/src/main.rs
Maxime Van Hees 2345b73e19 UI changes
2024-08-26 13:18:25 +02:00

465 lines
16 KiB
Rust

#![allow(non_snake_case)]
mod api;
use dioxus::prelude::*;
use dioxus_free_icons::icons::fa_solid_icons::{FaChevronLeft, FaChevronRight};
use dioxus_free_icons::Icon;
use mycelium::peer_manager::PeerType;
use std::cmp::Ordering;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use tracing::Level;
const _: &str = manganis::mg!(file("assets/styles.css"));
const SERVER_ADDR: std::net::SocketAddr =
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8989);
#[derive(Clone, Routable, Debug, PartialEq)]
#[rustfmt::skip]
pub enum Route {
#[layout(Layout)]
#[route("/")]
Home,
#[route("/peers")]
Peers,
#[route("/routes")]
Routes,
#[end_layout]
#[route("/:..route")]
PageNotFound { route: Vec<String> },
}
fn main() {
// Init logger
dioxus_logger::init(Level::INFO).expect("failed to init logger");
dioxus::launch(App);
}
#[component]
fn Layout() -> Element {
let mut 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" },
button {
class: if *sidebar_collapsed.read() { "toggle-sidebar collapsed" } else { "toggle-sidebar" },
onclick: {
let sb_collapsed = *sidebar_collapsed.read();
move |_| sidebar_collapsed.set(!sb_collapsed)
},
// i {
// if *sidebar_collapsed.read() {
// Icon {
// fill: "white",
// icon: FaChevronRight,
// }
// } else {
// Icon {
// fill: "white",
// icon: FaChevronLeft,
// }
// }
// }
Icon {
fill: "white",
icon: FaChevronLeft,
}
}
Outlet::<Route> {}
}
}
}
}
}
#[component]
fn App() -> Element {
rsx! {
Router::<Route> {}
}
}
#[component]
fn Header() -> Element {
let fetched_node_info = use_resource(move || api::get_node_info(SERVER_ADDR));
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]
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" } }
}
}
}
}
#[component]
fn Home() -> Element {
let fetched_node_info = use_resource(move || api::get_node_info(SERVER_ADDR));
match &*fetched_node_info.read_unchecked() {
Some(Ok(info)) => rsx! {
div { class: "node-info",
h3 { "Node subnet: {info.node_subnet}" }
h3 { "Node public key: {info.node_pubkey}" }
}
Outlet::<Route> {}
},
Some(Err(e)) => rsx! { div { "An error has occurred while fetching peers: {e}" }},
None => rsx! { div { "Loading peers..." }},
}
}
#[component]
fn Peers() -> Element {
let fetched_peers = use_resource(move || api::get_peers(SERVER_ADDR));
match &*fetched_peers.read_unchecked() {
Some(Ok(peers)) => rsx! { {PeersTable(peers.clone()) } },
Some(Err(e)) => rsx! { div { "An error has occurred while fetching the peers: {e}" } },
None => rsx! { div { "Loading peers..." } },
}
}
#[component]
fn Routes() -> Element {
rsx! {
SelectedRoutesTable {}
FallbackRoutesTable {}
}
}
#[component]
fn PageNotFound(route: Vec<String>) -> Element {
rsx! {
p { "Page not found"}
}
}
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 {}
#[derive(Clone)]
enum SortDirection {
Ascending,
Descending,
}
fn sort_peers(
peers: &mut [mycelium::peer_manager::PeerStats],
column: &str,
direction: &SortDirection,
) {
peers.sort_by(|a, b| {
let cmp = match column {
"Endpoint" => a.endpoint.cmp(&b.endpoint),
"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(),
}
});
}
fn PeersTable(peers: Vec<mycelium::peer_manager::PeerStats>) -> Element {
let mut current_page = use_signal(|| 0);
let items_per_page = 20;
let mut sort_column = use_signal(|| "Type".to_string());
let mut sort_direction = use_signal(|| SortDirection::Ascending);
let peers_len = peers.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((peers_len - 1) as i32 / items_per_page as i32) as usize,
);
};
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);
}
};
let sorted_peers = use_memo(move || {
let mut sorted = peers.clone();
sort_peers(&mut sorted, &sort_column.read(), &sort_direction.read());
sorted
});
let start = current_page * items_per_page;
let end = (start + items_per_page).min(peers_len);
let current_peers = &sorted_peers.read()[start..end];
rsx! {
div { class: "peers-table",
h2 { "Peers" }
div { class: "table-container",
table {
thead {
tr {
th { class: "endpoint-column",
onclick: move |_| sort_peers_signal("Endpoint".to_string()),
"Endpoint {get_sort_indicator(sort_column, sort_direction, \"Endpoint\".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 {
tr {
td { class: "endpoint-column", "{peer.endpoint}" }
td { class: "type-column", "{peer.pt}" }
td { class: "connection-state-column", "{peer.connection_state}" }
td { class: "tx-bytes-column", "{peer.tx_bytes}" }
td { class: "rx-bytes-column", "{peer.rx_bytes}" }
}
}
}
}
}
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,
onclick: move |_| change_page(1),
"Next"
}
}
}
}
}
#[component]
fn SelectedRoutesTable() -> Element {
let fetched_selected_routes = use_resource(move || api::get_selected_routes(SERVER_ADDR));
match &*fetched_selected_routes.read_unchecked() {
Some(Ok(routes)) => {
rsx! { { RoutesTable(routes.clone(), "Selected".to_string()) } }
}
Some(Err(e)) => rsx! { div { "An error has occurred while fetching selected routes: {e}" }},
None => rsx! { div { "Loading selected routes..." }},
}
}
#[component]
fn FallbackRoutesTable() -> Element {
let fetched_fallback_routes = use_resource(move || api::get_fallback_routes(SERVER_ADDR));
match &*fetched_fallback_routes.read_unchecked() {
Some(Ok(routes)) => {
rsx! { { RoutesTable(routes.clone(), "Fallback".to_string()) } }
}
Some(Err(e)) => rsx! { div { "An error has occurred while fetching fallback routes: {e}" }},
None => rsx! { div { "Loading fallback routes..." }},
}
}
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()
}
}
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(),
}
});
}
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);
}
};
let sorted_routes = use_memo(move || {
let mut sorted = routes.clone();
sort_routes(&mut sorted, &sort_column.read(), &sort_direction.read());
sorted
});
let start = current_page * items_per_page;
let end = (start + items_per_page).min(routes_len);
let current_routes = &sorted_routes.read()[start..end];
rsx! {
div { class: "{table_name.to_lowercase()}-routes",
h2 { "{table_name} Routes" }
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"
}
}
}
}
}