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