use std::cmp::max;
use std::cmp::min;
use std::net::IpAddr;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use crossterm::event::Event;
use crossterm::event::KeyCode;
use crossterm::event::KeyEventKind;
use itertools::Itertools;
use multiaddr::Protocol;
use neptune_cash::application::rpc::auth;
use neptune_cash::protocol::peer::peer_info::PeerInfo;
use ratatui::layout::Constraint;
use ratatui::layout::Margin;
use ratatui::layout::Offset;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Borders;
use ratatui::widgets::Cell;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Row;
use ratatui::widgets::StatefulWidget;
use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use tarpc::context;
use tokio::select;
use tokio::task::JoinHandle;
use tokio::time::sleep;
use unicode_width::UnicodeWidthStr;
use super::dashboard_app::Config;
use super::dashboard_app::DashboardEvent;
use super::overview_screen::VerticalRectifier;
use super::screen::Screen;
use crate::dashboard_rpc_client::DashboardRpcClient;
use crate::scrollable_table::ScrollableTable;
type PeerInfoArc = Arc<std::sync::Mutex<Vec<PeerInfo>>>;
type DashboardEventArc = Arc<std::sync::Mutex<Option<DashboardEvent>>>;
type JoinHandleArc = Arc<Mutex<JoinHandle<()>>>;
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum SortOrder {
Ascending,
Descending,
}
impl SortOrder {
fn compare<T: Ord>(&self, a: T, b: T) -> std::cmp::Ordering {
match self {
Self::Ascending => a.cmp(&b),
Self::Descending => b.cmp(&a),
}
}
fn reverse(&mut self) {
*self = match self {
Self::Ascending => Self::Descending,
Self::Descending => Self::Ascending,
}
}
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum PeerSortColumn {
Ip,
Version,
ConnectionEstablished,
Standing,
LastPunishment,
LastReward,
}
#[derive(Debug, Clone)]
pub struct PeersScreen {
#[expect(dead_code)]
config: Arc<Config>,
active: bool,
fg: Color,
bg: Color,
in_focus: bool,
data: PeerInfoArc,
server: Arc<DashboardRpcClient>,
token: auth::Token,
poll_task: Option<JoinHandleArc>,
escalatable_event: DashboardEventArc,
scrollable_table: ScrollableTable<PeerInfo>,
sort_column: PeerSortColumn,
sort_order: SortOrder,
}
impl PeersScreen {
pub fn new(
config: Arc<Config>,
rpc_server: Arc<DashboardRpcClient>,
token: auth::Token,
) -> Self {
let data = Arc::new(Mutex::new(vec![]));
Self {
sort_column: config.peer_sort_column,
sort_order: config.peer_sort_order,
config,
active: false,
fg: Color::Gray,
bg: Color::Black,
in_focus: false,
data: data.clone(),
server: rpc_server,
token,
poll_task: None,
escalatable_event: Arc::new(std::sync::Mutex::new(None)),
scrollable_table: ScrollableTable::new(data.clone()),
}
}
async fn run_polling_loop(
rpc_client: Arc<DashboardRpcClient>,
token: auth::Token,
peer_info: PeerInfoArc,
escalatable_event_arc: DashboardEventArc,
) -> ! {
macro_rules! setup_poller {
($name: ident) => {
let $name = sleep(Duration::from_millis(1));
tokio::pin!($name);
};
}
macro_rules! reset_poller {
($name: ident, $period: expr) => {
$name.as_mut().reset(tokio::time::Instant::now() + $period);
};
}
setup_poller!(balance);
loop {
select! {
_ = &mut balance => {
match rpc_client.peer_info(context::current(), token).await {
Ok(Ok(pi)) => {
*peer_info.lock().unwrap() = pi;
*escalatable_event_arc.lock().unwrap() = Some(DashboardEvent::RefreshScreen);
reset_poller!(balance, Duration::from_secs(10));
},
Ok(Err(e)) => {
*escalatable_event_arc.lock().unwrap() = Some(DashboardEvent::Shutdown(e.to_string()));
}
Err(e) => {
*escalatable_event_arc.lock().unwrap() = Some(DashboardEvent::Shutdown(e.to_string()));
}
}
}
}
}
}
fn char_to_column(c: char) -> Option<PeerSortColumn> {
match c {
'i' => Some(PeerSortColumn::Ip),
'v' => Some(PeerSortColumn::Version),
's' => Some(PeerSortColumn::Standing),
'c' => Some(PeerSortColumn::ConnectionEstablished),
'p' => Some(PeerSortColumn::LastPunishment),
'r' => Some(PeerSortColumn::LastReward),
_ => None,
}
}
fn set_sort_column(&mut self, c: char) {
if let Some(column) = Self::char_to_column(c) {
self.sort_column = column;
self.sort_order.reverse();
}
}
pub fn handle(&mut self, event: DashboardEvent) -> Option<DashboardEvent> {
let mut escalate_event = None;
if self.in_focus {
if let DashboardEvent::ConsoleEvent(Event::Key(key)) = event {
if key.kind == KeyEventKind::Press {
if self.scrollable_table.handle_navigation(&event) {
return None;
}
match key.code {
KeyCode::Char(c) if Self::char_to_column(c).is_some() => {
self.set_sort_column(c);
return None;
}
KeyCode::Char('x') => {
self.clear_selected_peer_standing();
return None;
}
_ => {
escalate_event = Some(event);
}
}
}
}
}
escalate_event
}
pub fn clear_selected_peer_standing(&self) {
let Some(selected_index) = self.scrollable_table.selected_data_index() else {
return;
};
let peer_ip = {
let items = self.scrollable_table.data().lock().unwrap();
items.get(selected_index).and_then(|peer| {
peer.address().iter().find_map(|component| match component {
Protocol::Ip4(ip) => Some(IpAddr::V4(ip)),
Protocol::Ip6(ip) => Some(IpAddr::V6(ip)),
_ => None,
})
})
};
let Some(ip) = peer_ip else { return };
let (rpc_client, token, data_arc, event_arc) = (
self.server.clone(),
self.token,
self.data.clone(),
self.escalatable_event.clone(),
);
tokio::spawn(async move {
if rpc_client
.clear_peer_standing(context::current(), token, ip)
.await
.is_ok()
{
if let Ok(Ok(peer_info)) = rpc_client.peer_info(context::current(), token).await {
*data_arc.lock().unwrap() = peer_info;
*event_arc.lock().unwrap() = Some(DashboardEvent::RefreshScreen);
}
}
});
}
}
impl Screen for PeersScreen {
fn activate(&mut self) {
self.active = true;
let server_arc = self.server.clone();
let data_arc = self.data.clone();
let escalatable_event_arc = self.escalatable_event.clone();
let token = self.token;
self.poll_task = Some(Arc::new(Mutex::new(tokio::spawn(async move {
PeersScreen::run_polling_loop(server_arc, token, data_arc, escalatable_event_arc).await;
}))));
}
fn deactivate(&mut self) {
self.active = false;
if let Some(task_handle) = &self.poll_task {
(*task_handle.lock().unwrap()).abort();
}
}
fn focus(&mut self) {
self.fg = Color::White;
self.in_focus = true;
}
fn unfocus(&mut self) {
self.fg = Color::Gray;
self.in_focus = false;
}
fn escalatable_event(&self) -> DashboardEventArc {
self.escalatable_event.clone()
}
}
impl Widget for PeersScreen {
fn render(mut self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
let style: Style = if self.in_focus {
Style::default().fg(Color::LightCyan).bg(self.bg)
} else {
Style::default().fg(Color::Gray).bg(self.bg)
};
Block::default()
.borders(Borders::ALL)
.title("Peers")
.style(style)
.render(area, buf);
let inner = area
.offset(Offset {
y: 2, x: 0,
})
.inner(Margin {
vertical: 0,
horizontal: 2,
});
let mut vrecter = VerticalRectifier::new(inner);
let peer_count_rect = vrecter.next((5).try_into().unwrap());
let num_peers = self.data.lock().unwrap().len();
let peer_count_buf = if self.in_focus {
format!("Peers connected: {num_peers} sort-keys: i, v, c, s, p, r clear-peer-standing: x")
} else {
format!("Peers connected: {num_peers} press enter for options")
};
let peer_count = Line::from(vec![Span::from(peer_count_buf)]);
let peer_count_widget = Paragraph::new(peer_count).style(style);
peer_count_widget.render(peer_count_rect, buf);
let style = Style::default().fg(self.fg).bg(self.bg);
let selected_style = style.add_modifier(Modifier::REVERSED);
let header = vec![
"ip",
"version",
"connection established",
"standing",
"last punishment",
"last reward",
];
let mut pi = self.data.lock().unwrap();
match self.sort_column {
PeerSortColumn::Ip => {
pi.sort_by(|a, b| self.sort_order.compare(a.address(), b.address()))
}
PeerSortColumn::Version => {
pi.sort_by(|a, b| self.sort_order.compare(a.version(), b.version()))
}
PeerSortColumn::Standing => pi.sort_by(|a, b| {
self.sort_order
.compare(a.standing().standing, b.standing().standing)
}),
PeerSortColumn::ConnectionEstablished => pi.sort_by(|a, b| {
self.sort_order
.compare(a.connection_established(), b.connection_established())
}),
PeerSortColumn::LastPunishment => pi.sort_by(|a, b| {
self.sort_order.compare(
a.standing()
.latest_punishment
.map(|p| p.0.to_string())
.unwrap_or_default(),
b.standing()
.latest_punishment
.map(|p| p.0.to_string())
.unwrap_or_default(),
)
}),
PeerSortColumn::LastReward => pi.sort_by(|a, b| {
self.sort_order.compare(
a.standing()
.latest_reward
.map(|r| r.0.to_string())
.unwrap_or_default(),
b.standing()
.latest_reward
.map(|r| r.0.to_string())
.unwrap_or_default(),
)
}),
};
let matrix = pi
.iter()
.map(|pi| {
let latest_punishment: Option<String> = pi
.standing()
.latest_punishment
.map(|(peer_sanction, _timestamp)| peer_sanction.to_string());
let latest_reward: Option<String> = pi
.standing()
.latest_reward
.map(|(peer_sanction, _timestamp)| peer_sanction.to_string());
let connection_established = pi
.connection_established()
.duration_since(std::time::UNIX_EPOCH)
.unwrap();
vec![
pi.address().to_string(),
pi.version().to_string(),
neptune_cash::utc_timestamp_to_localtime(connection_established.as_millis())
.map(|ts| ts.to_string())
.unwrap_or("-".to_string()),
format!("{:>8}", pi.standing().to_string()),
latest_punishment.unwrap_or_default(),
latest_reward.unwrap_or_default(),
]
})
.collect_vec();
let ncols = header.len();
let mut widths: Vec<usize> = header.iter().map(|h| h.width()).collect();
for (i, w) in widths.iter_mut().enumerate() {
if let Some(body_max) = matrix.iter().map(|row| row[i].width()).max() {
*w = max(*w, body_max);
}
}
let mut widths_with_bars = vec![1];
widths_with_bars.append(
&mut widths
.iter()
.zip(vec![1; ncols].iter())
.map(|(w, o)| vec![*w, *o])
.collect_vec()
.concat(),
);
let mut header_with_bars = vec!["│".to_string()];
header_with_bars.append(
&mut header
.clone()
.into_iter()
.zip(vec!["│"; ncols].iter())
.map(|(h, &b)| vec![h.to_string(), b.to_string()])
.collect_vec()
.concat(),
);
let mut top_with_bars = widths_with_bars
.iter()
.enumerate()
.map(|(i, w)| {
if i % 2 == 0 {
"┬".to_string()
} else {
"─".to_string().repeat(*w)
}
})
.collect_vec();
top_with_bars[0] = "╭".to_string();
*top_with_bars.last_mut().unwrap() = "╮".to_string();
let mut separator_with_bars = widths_with_bars
.iter()
.enumerate()
.map(|(i, w)| {
if i % 2 == 0 {
"┼".to_string()
} else {
"─".to_string().repeat(*w)
}
})
.collect_vec();
separator_with_bars[0] = "├".to_string();
*separator_with_bars.last_mut().unwrap() = "┤".to_string();
let mut footer_with_bars = widths_with_bars
.iter()
.enumerate()
.map(|(i, w)| {
if i % 2 == 0 {
"┴".to_string()
} else {
"─".to_string().repeat(*w)
}
})
.collect_vec();
footer_with_bars[0] = "╰".to_string();
*footer_with_bars.last_mut().unwrap() = "╯".to_string();
let mut body = matrix
.iter()
.map(|row| {
let mut row_with_bars = vec!["│".to_string()];
row_with_bars.append(
&mut row
.iter()
.zip(vec!["│"; ncols].iter())
.map(|(r, &b)| vec![r.to_string(), b.to_string()])
.collect_vec()
.concat(),
);
row_with_bars
})
.map(|row| Row::new(row.iter().map(|c| Cell::from(c.to_string()))))
.collect_vec();
for (i, item) in body.iter_mut().enumerate() {
if i % 2 == 0 {
*item = item.clone().style(Style::default().bg(Color::DarkGray));
}
}
let mut rows: Vec<Row> = vec![
Row::new(top_with_bars),
Row::new(header_with_bars),
Row::new(separator_with_bars),
];
rows.append(&mut body);
rows.push(Row::new(footer_with_bars));
let width_constraints = widths_with_bars
.iter()
.map(|w| Constraint::Length(*w as u16))
.collect_vec();
let table = Table::new(rows, width_constraints)
.style(style)
.row_highlight_style(selected_style);
vrecter.set_width(min(
inner.width,
widths.iter().sum::<usize>() as u16 + 3 * widths.len() as u16 + 1,
));
let mut table_rect = vrecter.remaining();
table_rect.width = min(
table_rect.width,
widths.iter().sum::<usize>() as u16 + 3 * widths.len() as u16 + 1,
);
StatefulWidget::render(table, table_rect, buf, self.scrollable_table.state_mut());
}
}