Update tunnel stats every redraw, auto redraw once a second
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 6m21s

This commit is contained in:
Dreaded_X 2025-04-16 21:05:22 +02:00
parent fe1d5b8f72
commit 413d9f2157
Signed by: Dreaded_X
GPG Key ID: FA5F485356B0D2D4
3 changed files with 116 additions and 72 deletions

View File

@ -1,5 +1,6 @@
use std::cmp::{self, max}; use std::cmp::{self, max};
use std::io::Write as _; use std::io::Write as _;
use std::time::Duration;
use futures::StreamExt; use futures::StreamExt;
use git_version::git_version; use git_version::git_version;
@ -11,17 +12,18 @@ use ratatui::widgets::{
Block, BorderType, Cell, Clear, HighlightSpacing, Paragraph, Row, Table, TableState, Block, BorderType, Cell, Clear, HighlightSpacing, Paragraph, Row, Table, TableState,
}; };
use ratatui::{Frame, Terminal}; use ratatui::{Frame, Terminal};
use tokio::select;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
use tracing::error; use tracing::error;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::io::TerminalHandle; use crate::io::TerminalHandle;
use crate::tunnel::Tunnel; use crate::tunnel::{Tunnel, TunnelRow};
enum Message { enum Message {
Resize { width: u16, height: u16 }, Resize { width: u16, height: u16 },
Redraw, Redraw,
Rows(Vec<Vec<Span<'static>>>), Rows(Vec<TunnelRow>),
Select(Option<usize>), Select(Option<usize>),
Rename(Option<String>), Rename(Option<String>),
Help(String), Help(String),
@ -30,7 +32,7 @@ enum Message {
struct RendererInner { struct RendererInner {
state: TableState, state: TableState,
rows: Vec<Vec<Span<'static>>>, rows: Vec<TunnelRow>,
input: Option<String>, input: Option<String>,
rx: UnboundedReceiver<Message>, rx: UnboundedReceiver<Message>,
} }
@ -107,6 +109,22 @@ impl RendererInner {
(height as u16, Paragraph::new(text).centered().block(block)) (height as u16, Paragraph::new(text).centered().block(block))
} }
fn compute_widths(&mut self, rows: &Vec<Vec<Span<'static>>>) -> Vec<Constraint> {
let table_header = Tunnel::header();
std::iter::once(&table_header)
.chain(rows)
.map(|row| row.iter().map(|cell| cell.width() as u16))
.fold(vec![0; table_header.len()], |acc, row| {
acc.into_iter()
.zip(row)
.map(|v| cmp::max(v.0, v.1))
.collect()
})
.into_iter()
.map(|c| Constraint::Length(c + 1))
.collect()
}
fn render(&mut self, frame: &mut Frame) { fn render(&mut self, frame: &mut Frame) {
self.render_title(frame, frame.area()); self.render_title(frame, frame.area());
@ -122,28 +140,7 @@ impl RendererInner {
self.render_table(frame, chunks[0]); self.render_table(frame, chunks[0]);
frame.render_widget(footer, chunks[1]); frame.render_widget(footer, chunks[1]);
self.render_rename(frame, area);
if let Some(input) = &self.input {
self.render_rename(frame, area, input);
}
}
fn render_rename(&self, frame: &mut Frame, area: Rect, input: &str) {
let vertical = Layout::vertical([Constraint::Length(3)]).flex(Flex::Center);
let horizontal = Layout::horizontal([Constraint::Max(max(20, input.width() as u16 + 4))])
.flex(Flex::Center);
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
let title = Line::from("New name").centered();
let block = Block::bordered().title(title);
let text = Paragraph::new(format!(" {input}")).block(block);
frame.render_widget(Clear, area);
frame.render_widget(text, area);
frame.set_cursor_position(Position::new(area.x + input.width() as u16 + 2, area.y + 1));
} }
fn render_title(&self, frame: &mut Frame, rect: Rect) { fn render_title(&self, frame: &mut Frame, rect: Rect) {
@ -152,28 +149,18 @@ impl RendererInner {
frame.render_widget(title, rect); frame.render_widget(title, rect);
} }
fn compute_widths(&mut self) -> Vec<Constraint> { fn render_table(&mut self, frame: &mut Frame<'_>, rect: Rect) {
let table_header = Tunnel::header();
std::iter::once(&table_header)
.chain(&self.rows)
.map(|row| row.iter().map(|cell| cell.width() as u16))
.fold(vec![0; table_header.len()], |acc, row| {
acc.into_iter()
.zip(row)
.map(|v| cmp::max(v.0, v.1))
.collect()
})
.into_iter()
.map(|c| Constraint::Length(c + 1))
.collect()
}
pub fn render_table(&mut self, frame: &mut Frame<'_>, rect: Rect) {
let highlight_style = Style::default().bold(); let highlight_style = Style::default().bold();
let header_style = Style::default().bold().reversed(); let header_style = Style::default().bold().reversed();
let row_style = Style::default(); let row_style = Style::default();
let rows = self.rows.iter().map(|row| { let r = self
.rows
.iter()
.map(From::from)
.collect::<Vec<Vec<Span<'static>>>>();
let rows = r.iter().map(|row| {
row.iter() row.iter()
.cloned() .cloned()
.map(Cell::from) .map(Cell::from)
@ -195,7 +182,7 @@ impl RendererInner {
.rows(rows) .rows(rows)
.flex(Flex::Start) .flex(Flex::Start)
.column_spacing(3) .column_spacing(3)
.widths(self.compute_widths()) .widths(self.compute_widths(&r))
.row_highlight_style(highlight_style) .row_highlight_style(highlight_style)
.highlight_symbol(Line::from("> ")) .highlight_symbol(Line::from("> "))
.highlight_spacing(HighlightSpacing::Always); .highlight_spacing(HighlightSpacing::Always);
@ -203,11 +190,38 @@ impl RendererInner {
frame.render_stateful_widget(t, rect, &mut self.state); frame.render_stateful_widget(t, rect, &mut self.state);
} }
fn render_rename(&self, frame: &mut Frame, area: Rect) {
if let Some(input) = &self.input {
let vertical = Layout::vertical([Constraint::Length(3)]).flex(Flex::Center);
let horizontal =
Layout::horizontal([Constraint::Max(max(20, input.width() as u16 + 4))])
.flex(Flex::Center);
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
let title = Line::from("New name").centered();
let block = Block::bordered().title(title);
let text = Paragraph::new(format!(" {input}")).block(block);
frame.render_widget(Clear, area);
frame.render_widget(text, area);
frame.set_cursor_position(Position::new(area.x + input.width() as u16 + 2, area.y + 1));
}
}
pub async fn start( pub async fn start(
&mut self, &mut self,
mut terminal: Terminal<CrosstermBackend<TerminalHandle>>, mut terminal: Terminal<CrosstermBackend<TerminalHandle>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
while let Some(message) = self.rx.recv().await { loop {
select! {
message = self.rx.recv() => {
let Some(message) = message else {
break;
};
match message { match message {
Message::Resize { width, height } => { Message::Resize { width, height } => {
let rect = Rect::new(0, 0, width, height); let rect = Rect::new(0, 0, width, height);
@ -235,6 +249,13 @@ impl RendererInner {
} }
} }
} }
_ = tokio::time::sleep(Duration::from_secs(1)) => {
terminal.draw(|frame| {
self.render(frame);
})?;
}
}
}
Ok(()) Ok(())
} }

View File

@ -8,6 +8,7 @@ use registry::RegistryEntry;
use russh::server::Handle; use russh::server::Handle;
use tokio::sync::{RwLock, RwLockReadGuard}; use tokio::sync::{RwLock, RwLockReadGuard};
use tracing::trace; use tracing::trace;
pub use tui::TunnelRow;
use crate::io::{Stats, TrackStats}; use crate::io::{Stats, TrackStats};

View File

@ -1,9 +1,33 @@
use std::ops::Deref; use std::ops::Deref;
use std::sync::Arc;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::text::Span; use ratatui::text::Span;
use super::{Tunnel, TunnelAccess}; use super::{Tunnel, TunnelAccess};
use crate::io::Stats;
pub struct TunnelRow {
name: Span<'static>,
access: Span<'static>,
port: Span<'static>,
address: Span<'static>,
stats: Arc<Stats>,
}
impl From<&TunnelRow> for Vec<Span<'static>> {
fn from(row: &TunnelRow) -> Self {
vec![
row.name.clone(),
row.access.clone(),
row.port.clone(),
row.address.clone(),
row.stats.connections().to_string().into(),
row.stats.rx().to_string().into(),
row.stats.tx().to_string().into(),
]
}
}
impl Tunnel { impl Tunnel {
pub fn header() -> Vec<Span<'static>> { pub fn header() -> Vec<Span<'static>> {
@ -18,7 +42,7 @@ impl Tunnel {
] ]
} }
pub async fn to_row(tunnel: &Tunnel) -> Vec<Span<'static>> { pub async fn to_row(tunnel: &Tunnel) -> TunnelRow {
let access = match tunnel.inner.access.read().await.deref() { let access = match tunnel.inner.access.read().await.deref() {
TunnelAccess::Private(owner) => owner.clone().yellow(), TunnelAccess::Private(owner) => owner.clone().yellow(),
TunnelAccess::Protected => "PROTECTED".blue(), TunnelAccess::Protected => "PROTECTED".blue(),
@ -30,14 +54,12 @@ impl Tunnel {
.map(|address| format!("http://{address}").into()) .map(|address| format!("http://{address}").into())
.unwrap_or("FAILED".red()); .unwrap_or("FAILED".red());
vec![ TunnelRow {
tunnel.registry_entry.get_name().to_owned().into(), name: tunnel.registry_entry.get_name().to_string().into(),
access, access,
tunnel.inner.port.to_string().into(), port: tunnel.inner.port.to_string().into(),
address, address,
tunnel.inner.stats.connections().to_string().into(), stats: tunnel.inner.stats.clone(),
tunnel.inner.stats.rx().to_string().into(), }
tunnel.inner.stats.tx().to_string().into(),
]
} }
} }