1use std::{io::stdout, time::Duration};
2
3use crossterm::{
4 ExecutableCommand,
5 cursor::Show,
6 event::{DisableMouseCapture, EnableMouseCapture, KeyCode},
7 execute,
8 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
9 tty::IsTty,
10};
11use num_format::{Locale, ToFormattedString};
12use number_prefix::NumberPrefix;
13use ratatui::{
14 Frame, Terminal,
15 backend::CrosstermBackend,
16 layout::{Alignment, Constraint, Layout, Rect},
17 style::{Color, Modifier, Style},
18 text::{Line, Span},
19 widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap},
20};
21use tokio::sync::oneshot;
22
23use super::{
24 events::capture_key_press,
25 state::{self, ConnectionStatus},
26};
27use crate::internal_telemetry::is_allocation_tracking_enabled;
28
29trait ThousandsFormatter {
31 fn thousands_format(&self) -> String;
32}
33
34impl ThousandsFormatter for u32 {
35 fn thousands_format(&self) -> String {
36 match self {
37 0 => "--".into(),
38 _ => self.to_formatted_string(&Locale::en),
39 }
40 }
41}
42
43impl ThousandsFormatter for u64 {
44 fn thousands_format(&self) -> String {
45 match self {
46 0 => "--".into(),
47 _ => self.to_formatted_string(&Locale::en),
48 }
49 }
50}
51
52impl ThousandsFormatter for i64 {
53 fn thousands_format(&self) -> String {
54 match self {
55 0 => "--".into(),
56 _ => self.to_formatted_string(&Locale::en),
57 }
58 }
59}
60
61trait HumanFormatter {
63 fn human_format(&self) -> String;
64 fn human_format_bytes(&self) -> String;
65}
66
67impl HumanFormatter for i64 {
68 fn human_format(&self) -> String {
71 match self {
72 0 => "--".into(),
73 n => match NumberPrefix::decimal(*n as f64) {
74 NumberPrefix::Standalone(n) => n.to_string(),
75 NumberPrefix::Prefixed(p, n) => format!("{n:.2} {p}"),
76 },
77 }
78 }
79
80 fn human_format_bytes(&self) -> String {
83 match self {
84 0 => "--".into(),
85 n => match NumberPrefix::binary(*n as f64) {
86 NumberPrefix::Standalone(n) => n.to_string(),
87 NumberPrefix::Prefixed(p, n) => format!("{n:.2} {p}B"),
88 },
89 }
90 }
91}
92
93fn format_metric(total: i64, throughput: i64, human_metrics: bool) -> String {
94 match total {
95 0 => "N/A".to_string(),
96 v => format!(
97 "{} ({}/s)",
98 if human_metrics {
99 v.human_format()
100 } else {
101 v.thousands_format()
102 },
103 throughput.human_format()
104 ),
105 }
106}
107
108fn format_metric_bytes(total: i64, throughput: i64, human_metrics: bool) -> String {
109 match total {
110 0 => "N/A".to_string(),
111 v => format!(
112 "{} ({}/s)",
113 if human_metrics {
114 v.human_format_bytes()
115 } else {
116 v.thousands_format()
117 },
118 throughput.human_format_bytes()
119 ),
120 }
121}
122
123const NUM_COLUMNS: usize = if is_allocation_tracking_enabled() {
124 10
125} else {
126 9
127};
128
129static HEADER: [&str; NUM_COLUMNS] = [
130 "ID",
131 "Output",
132 "Kind",
133 "Type",
134 "Events In",
135 "Bytes In",
136 "Events Out",
137 "Bytes Out",
138 "Errors",
139 #[cfg(feature = "allocation-tracing")]
140 "Memory Used",
141];
142
143struct Widgets<'a> {
144 constraints: Vec<Constraint>,
145 url_string: &'a str,
146 opts: &'a super::Opts,
147 title: &'a str,
148}
149
150impl<'a> Widgets<'a> {
151 pub fn new(title: &'a str, url_string: &'a str, opts: &'a super::Opts) -> Self {
153 let constraints = vec![
154 Constraint::Length(3),
155 Constraint::Max(90),
156 Constraint::Length(3),
157 ];
158
159 Self {
160 constraints,
161 url_string,
162 opts,
163 title,
164 }
165 }
166
167 fn title(
169 &'a self,
170 f: &mut Frame,
171 area: Rect,
172 connection_status: &ConnectionStatus,
173 uptime: Duration,
174 ) {
175 let mut text = vec![
176 Span::from(self.url_string),
177 Span::styled(
178 format!(" | Sampling @ {}ms", self.opts.interval.thousands_format()),
179 Style::default().fg(Color::Gray),
180 ),
181 Span::from(" | "),
182 ];
183 text.extend(connection_status.as_ui_spans());
184 text.extend(vec![Span::from(format!(
185 " | Uptime: {}",
186 humantime::format_duration(uptime)
187 ))]);
188
189 let text = vec![Line::from(text)];
190
191 let block = Block::default().borders(Borders::ALL).title(Span::styled(
192 self.title,
193 Style::default()
194 .fg(Color::Green)
195 .add_modifier(Modifier::BOLD),
196 ));
197 let w = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
198
199 f.render_widget(w, area);
200 }
201
202 fn components_table(&self, f: &mut Frame, state: &state::State, area: Rect) {
205 let header = HEADER
207 .iter()
208 .map(|s| Cell::from(*s).style(Style::default().add_modifier(Modifier::BOLD)))
209 .collect::<Vec<_>>();
210
211 let mut items = Vec::new();
213 for (_, r) in state.components.iter() {
214 let mut data = vec![
215 r.key.id().to_string(),
216 if !r.has_displayable_outputs() {
217 "--"
218 } else {
219 Default::default()
220 }
221 .to_string(),
222 r.kind.clone(),
223 r.component_type.clone(),
224 ];
225
226 let formatted_metrics = [
227 format_metric(
228 r.received_events_total,
229 r.received_events_throughput_sec,
230 self.opts.human_metrics,
231 ),
232 format_metric_bytes(
233 r.received_bytes_total,
234 r.received_bytes_throughput_sec,
235 self.opts.human_metrics,
236 ),
237 format_metric(
238 r.sent_events_total,
239 r.sent_events_throughput_sec,
240 self.opts.human_metrics,
241 ),
242 format_metric_bytes(
243 r.sent_bytes_total,
244 r.sent_bytes_throughput_sec,
245 self.opts.human_metrics,
246 ),
247 if self.opts.human_metrics {
248 r.errors.human_format()
249 } else {
250 r.errors.thousands_format()
251 },
252 #[cfg(feature = "allocation-tracing")]
253 r.allocated_bytes.human_format_bytes(),
254 ];
255
256 data.extend_from_slice(&formatted_metrics);
257 items.push(Row::new(data).style(Style::default()));
258
259 if r.has_displayable_outputs() {
261 for (id, output) in r.outputs.iter() {
262 let sent_events_metric = format_metric(
263 output.sent_events_total,
264 output.sent_events_throughput_sec,
265 self.opts.human_metrics,
266 );
267 let mut data = [""; NUM_COLUMNS]
268 .into_iter()
269 .map(Cell::from)
270 .collect::<Vec<_>>();
271 data[1] = Cell::from(id.as_str());
272 data[5] = Cell::from(sent_events_metric);
273 items.push(Row::new(data).style(Style::default()));
274 }
275 }
276 }
277
278 let widths: &[Constraint] = if is_allocation_tracking_enabled() {
279 &[
280 Constraint::Percentage(13), Constraint::Percentage(8), Constraint::Percentage(4), Constraint::Percentage(9), Constraint::Percentage(10), Constraint::Percentage(12), Constraint::Percentage(10), Constraint::Percentage(12), Constraint::Percentage(8), Constraint::Percentage(14), ]
291 } else {
292 &[
293 Constraint::Percentage(13), Constraint::Percentage(12), Constraint::Percentage(9), Constraint::Percentage(6), Constraint::Percentage(12), Constraint::Percentage(14), Constraint::Percentage(12), Constraint::Percentage(14), Constraint::Percentage(8), ]
303 };
304 let w = Table::new(items, widths)
305 .header(Row::new(header).bottom_margin(1))
306 .block(Block::default().borders(Borders::ALL).title("Components"))
307 .column_spacing(2);
308 f.render_widget(w, area);
309 }
310
311 fn components_resize_window(&self, f: &mut Frame, area: Rect) {
313 let block = Block::default().borders(Borders::ALL).title("Components");
314 let w = Paragraph::new("Expand the window to > 80 chars to view metrics")
315 .block(block)
316 .wrap(Wrap { trim: true });
317
318 f.render_widget(w, area);
319 }
320
321 fn quit_box(&self, f: &mut Frame, area: Rect) {
323 let text = vec![Line::from("To quit, press ESC or 'q'")];
324
325 let block = Block::default()
326 .borders(Borders::ALL)
327 .border_style(Style::default().fg(Color::Gray));
328 let w = Paragraph::new(text)
329 .block(block)
330 .style(Style::default().fg(Color::Gray))
331 .alignment(Alignment::Left);
332
333 f.render_widget(w, area);
334 }
335
336 fn draw(&self, f: &mut Frame, state: state::State) {
338 let size = f.area();
339 let rects = Layout::default()
340 .constraints(self.constraints.clone())
341 .split(size);
342
343 self.title(f, rects[0], &state.connection_status, state.uptime);
344
345 if size.width >= 80 {
347 self.components_table(f, &state, rects[1]);
348 } else {
349 self.components_resize_window(f, rects[1]);
350 }
351
352 self.quit_box(f, rects[2]);
353 }
354}
355
356pub fn is_tty() -> bool {
358 stdout().is_tty()
359}
360
361pub async fn init_dashboard<'a>(
366 title: &'a str,
367 url: &'a str,
368 opts: &'a super::Opts,
369 mut state_rx: state::StateRx,
370 mut shutdown_rx: oneshot::Receiver<()>,
371) -> Result<(), Box<dyn std::error::Error>> {
372 let (mut key_press_rx, key_press_kill_tx) = capture_key_press();
374
375 let mut stdout = stdout();
378
379 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
380
381 enable_raw_mode()?;
383
384 let backend = CrosstermBackend::new(stdout);
386 let mut terminal = Terminal::new(backend)?;
387
388 terminal.clear()?;
390
391 let widgets = Widgets::new(title, url, opts);
392
393 loop {
394 tokio::select! {
395 Some(state) = state_rx.recv() => {
396 terminal.draw(|f| widgets.draw(f, state))?;
397 },
398 k = key_press_rx.recv() => {
399 if let KeyCode::Esc | KeyCode::Char('q') = k.unwrap() {
400 _ = key_press_kill_tx.send(());
401 break
402 }
403 }
404 _ = &mut shutdown_rx => {
405 _ = key_press_kill_tx.send(());
406 break
407 }
408 }
409 }
410
411 terminal.backend_mut().execute(DisableMouseCapture)?;
413 terminal.backend_mut().execute(LeaveAlternateScreen)?;
414 terminal.backend_mut().execute(Show)?;
415
416 disable_raw_mode()?;
417
418 Ok(())
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn format_zero() {
428 const N: i64 = 0;
429
430 assert_eq!(N.thousands_format(), "--");
431 assert_eq!(N.human_format(), "--");
432 }
433
434 #[test]
435 fn format_hundred() {
437 const N: i64 = 100;
438
439 assert_eq!(N.thousands_format(), "100");
440 assert_eq!(N.human_format(), "100");
441 }
442
443 #[test]
444 fn format_thousands() {
446 const N: i64 = 1_000;
447
448 assert_eq!(N.thousands_format(), "1,000");
449 assert_eq!(N.human_format(), "1.00 k");
450 }
451
452 #[test]
453 fn format_thousands_no_rounding() {
455 const N: i64 = 1_500;
456
457 assert_eq!(N.thousands_format(), "1,500");
458 assert_eq!(N.human_format(), "1.50 k");
459 }
460
461 #[test]
462 fn format_thousands_round_down() {
464 const N: i64 = 1_514;
465
466 assert_eq!(N.thousands_format(), "1,514");
467 assert_eq!(N.human_format(), "1.51 k");
468 }
469
470 #[test]
471 fn format_thousands_round_up() {
473 const N: i64 = 1_999;
474
475 assert_eq!(N.thousands_format(), "1,999");
476 assert_eq!(N.human_format(), "2.00 k");
477 }
478
479 #[test]
480 fn format_millions() {
482 const N: i64 = 1_000_000;
483
484 assert_eq!(N.thousands_format(), "1,000,000");
485 assert_eq!(N.human_format(), "1.00 M");
486 }
487
488 #[test]
489 fn format_billions() {
491 const N: i64 = 1_000_000_000;
492
493 assert_eq!(N.thousands_format(), "1,000,000,000");
494 assert_eq!(N.human_format(), "1.00 G");
495 }
496
497 #[test]
498 fn format_trillions() {
500 const N: i64 = 1_100_000_000_000;
501
502 assert_eq!(N.thousands_format(), "1,100,000,000,000");
503 assert_eq!(N.human_format(), "1.10 T");
504 }
505
506 #[test]
507 fn format_bytes() {
509 const N: i64 = 1024;
510
511 assert_eq!(N.human_format_bytes(), "1.00 KiB");
512 assert_eq!((N * N).human_format_bytes(), "1.00 MiB");
513 assert_eq!((N * (N * N)).human_format_bytes(), "1.00 GiB");
514 assert_eq!((N * (N * (N * N))).human_format_bytes(), "1.00 TiB");
515 assert_eq!((N * (N * (N * (N * N)))).human_format_bytes(), "1.00 PiB");
516 }
517}