1use std::io::Write;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
15use std::thread;
16use std::time::Duration;
17
18const SPARKLINE_RING_SIZE: usize = 60;
20
21const TUI_TICK_INTERVAL_MS: u64 = 16;
23
24const DEFAULT_TERMINAL_HEIGHT: u16 = 24;
26
27const DEFAULT_TERMINAL_WIDTH: u16 = 80;
29
30const LEVEL_BAR_WIDTH: usize = 10;
32
33#[cfg(all(not(windows), feature = "tui"))]
34#[must_use]
40pub fn get_terminal_height_of(handle: &impl std::os::fd::AsFd) -> u16 {
41 terminal_size::terminal_size_of(handle).map_or(
42 DEFAULT_TERMINAL_HEIGHT,
43 |(_, terminal_size::Height(h))| h,
44 )
45}
46
47#[cfg(feature = "tui")]
48fn get_terminal_height() -> u16 {
49 terminal_size::terminal_size().map_or(
50 DEFAULT_TERMINAL_HEIGHT,
51 |(_, terminal_size::Height(h))| h,
52 )
53}
54
55#[cfg(not(feature = "tui"))]
56const fn get_terminal_height() -> u16 {
57 DEFAULT_TERMINAL_HEIGHT
58}
59
60#[repr(align(64))]
62#[derive(Debug, Default)]
63pub struct TuiMetrics {
64 pub total_events: AtomicUsize,
66 pub error_count: AtomicUsize,
68 pub active_spans: AtomicUsize,
70 pub throughput: AtomicUsize,
72 pub peak_throughput: AtomicUsize,
74 pub start_epoch_secs: AtomicUsize,
76
77 pub level_trace: AtomicUsize,
80 pub level_debug: AtomicUsize,
82 pub level_info: AtomicUsize,
84 pub level_warn: AtomicUsize,
86 pub level_error: AtomicUsize,
88 pub level_fatal: AtomicUsize,
90 pub level_critical: AtomicUsize,
92 pub dropped_events: AtomicUsize,
94
95 pub fmt_clf: AtomicUsize,
98 pub fmt_json: AtomicUsize,
100 pub fmt_cef: AtomicUsize,
102 pub fmt_elf: AtomicUsize,
104 pub fmt_w3c: AtomicUsize,
106 pub fmt_gelf: AtomicUsize,
108 pub fmt_apache: AtomicUsize,
110 pub fmt_logstash: AtomicUsize,
112 pub fmt_log4j: AtomicUsize,
114 pub fmt_ndjson: AtomicUsize,
116 pub fmt_mcp: AtomicUsize,
118 pub fmt_otlp: AtomicUsize,
120 pub fmt_logfmt: AtomicUsize,
122 pub fmt_ecs: AtomicUsize,
124}
125
126impl TuiMetrics {
127 pub fn inc_events(&self) {
129 self.total_events.fetch_add(1, Ordering::Relaxed);
130 }
131
132 pub fn inc_errors(&self) {
134 self.error_count.fetch_add(1, Ordering::Relaxed);
135 }
136
137 pub fn inc_spans(&self) {
139 self.active_spans.fetch_add(1, Ordering::Relaxed);
140 }
141
142 pub fn dec_spans(&self) {
144 self.active_spans.fetch_sub(1, Ordering::Relaxed);
145 }
146
147 pub fn inc_dropped(&self) {
149 self.dropped_events.fetch_add(1, Ordering::Relaxed);
150 }
151
152 pub fn inc_level(&self, level: crate::log_level::LogLevel) {
154 use crate::log_level::LogLevel;
155 match level {
156 LogLevel::TRACE => {
157 self.level_trace.fetch_add(1, Ordering::Relaxed);
158 }
159 LogLevel::DEBUG => {
160 self.level_debug.fetch_add(1, Ordering::Relaxed);
161 }
162 LogLevel::INFO => {
163 self.level_info.fetch_add(1, Ordering::Relaxed);
164 }
165 LogLevel::WARN => {
166 self.level_warn.fetch_add(1, Ordering::Relaxed);
167 }
168 LogLevel::ERROR => {
169 self.level_error.fetch_add(1, Ordering::Relaxed);
170 }
171 LogLevel::FATAL => {
172 self.level_fatal.fetch_add(1, Ordering::Relaxed);
173 }
174 LogLevel::CRITICAL => {
175 self.level_critical.fetch_add(1, Ordering::Relaxed);
176 }
177 _ => {}
178 }
179 }
180
181 pub fn inc_format(&self, format: crate::log_format::LogFormat) {
183 use crate::log_format::LogFormat;
184 match format {
185 LogFormat::CLF => {
186 self.fmt_clf.fetch_add(1, Ordering::Relaxed);
187 }
188 LogFormat::JSON => {
189 self.fmt_json.fetch_add(1, Ordering::Relaxed);
190 }
191 LogFormat::CEF => {
192 self.fmt_cef.fetch_add(1, Ordering::Relaxed);
193 }
194 LogFormat::ELF => {
195 self.fmt_elf.fetch_add(1, Ordering::Relaxed);
196 }
197 LogFormat::W3C => {
198 self.fmt_w3c.fetch_add(1, Ordering::Relaxed);
199 }
200 LogFormat::GELF => {
201 self.fmt_gelf.fetch_add(1, Ordering::Relaxed);
202 }
203 LogFormat::ApacheAccessLog => {
204 self.fmt_apache.fetch_add(1, Ordering::Relaxed);
205 }
206 LogFormat::Logstash => {
207 self.fmt_logstash.fetch_add(1, Ordering::Relaxed);
208 }
209 LogFormat::Log4jXML => {
210 self.fmt_log4j.fetch_add(1, Ordering::Relaxed);
211 }
212 LogFormat::NDJSON => {
213 self.fmt_ndjson.fetch_add(1, Ordering::Relaxed);
214 }
215 LogFormat::MCP => {
216 self.fmt_mcp.fetch_add(1, Ordering::Relaxed);
217 }
218 LogFormat::OTLP => {
219 self.fmt_otlp.fetch_add(1, Ordering::Relaxed);
220 }
221 LogFormat::Logfmt => {
222 self.fmt_logfmt.fetch_add(1, Ordering::Relaxed);
223 }
224 LogFormat::ECS => {
225 self.fmt_ecs.fetch_add(1, Ordering::Relaxed);
226 }
227 }
228 }
229}
230
231#[cfg(feature = "tui")]
233fn get_terminal_width() -> u16 {
234 terminal_size::terminal_size().map_or(
235 DEFAULT_TERMINAL_WIDTH,
236 |(terminal_size::Width(w), _)| w,
237 )
238}
239
240#[cfg(not(feature = "tui"))]
242const fn get_terminal_width() -> u16 {
243 DEFAULT_TERMINAL_WIDTH
244}
245
246const SPARK_CHARS: [char; 8] = [
248 '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}',
249 '\u{2586}', '\u{2587}', '\u{2588}',
250];
251
252fn render_sparkline(
254 ring: &[usize; SPARKLINE_RING_SIZE],
255 cursor: usize,
256) -> String {
257 let max_val = ring.iter().copied().max().unwrap_or(1).max(1);
258 let mut out = String::with_capacity(SPARKLINE_RING_SIZE);
259 for i in 0..SPARKLINE_RING_SIZE {
260 let idx = (cursor + i) % SPARKLINE_RING_SIZE;
261 let scaled = (ring[idx] * 7) / max_val;
262 out.push(SPARK_CHARS[scaled.min(7)]);
263 }
264 out
265}
266
267fn render_level_bar(count: usize, total: usize) -> String {
269 if total == 0 {
270 return "\u{2591}".repeat(LEVEL_BAR_WIDTH);
271 }
272 let filled =
273 ((count * LEVEL_BAR_WIDTH) / total).min(LEVEL_BAR_WIDTH);
274 let mut bar = String::with_capacity(LEVEL_BAR_WIDTH * 3);
275 for _ in 0..filled {
276 bar.push('\u{2588}');
277 }
278 for _ in filled..LEVEL_BAR_WIDTH {
279 bar.push('\u{2591}');
280 }
281 bar
282}
283
284fn format_uptime(secs: u64) -> String {
286 let h = secs / 3600;
287 let m = (secs % 3600) / 60;
288 let s = secs % 60;
289 format!("{h:02}:{m:02}:{s:02}")
290}
291
292pub fn build_fmt_line(metrics: &TuiMetrics) -> String {
294 let fmt_counts: Vec<(&str, usize)> = [
295 ("CLF", metrics.fmt_clf.load(Ordering::Relaxed)),
296 ("JSON", metrics.fmt_json.load(Ordering::Relaxed)),
297 ("CEF", metrics.fmt_cef.load(Ordering::Relaxed)),
298 ("ELF", metrics.fmt_elf.load(Ordering::Relaxed)),
299 ("W3C", metrics.fmt_w3c.load(Ordering::Relaxed)),
300 ("GELF", metrics.fmt_gelf.load(Ordering::Relaxed)),
301 ("Apache", metrics.fmt_apache.load(Ordering::Relaxed)),
302 ("Logstash", metrics.fmt_logstash.load(Ordering::Relaxed)),
303 ("Log4j", metrics.fmt_log4j.load(Ordering::Relaxed)),
304 ("NDJSON", metrics.fmt_ndjson.load(Ordering::Relaxed)),
305 ("MCP", metrics.fmt_mcp.load(Ordering::Relaxed)),
306 ("OTLP", metrics.fmt_otlp.load(Ordering::Relaxed)),
307 ("Logfmt", metrics.fmt_logfmt.load(Ordering::Relaxed)),
308 ("ECS", metrics.fmt_ecs.load(Ordering::Relaxed)),
309 ]
310 .into_iter()
311 .filter(|(_, c)| *c > 0)
312 .collect();
313
314 let mut fmt_line = String::new();
315 for (i, (name, count)) in fmt_counts.iter().enumerate() {
316 use std::fmt::Write as _;
317 if i > 0 {
318 fmt_line.push_str(" | ");
319 }
320 let _ = write!(fmt_line, "{name}: {count}");
321 }
322 if fmt_line.is_empty() {
323 fmt_line.push_str("(none)");
324 }
325 fmt_line
326}
327
328pub fn compute_level_bars(
332 metrics: &TuiMetrics,
333) -> (String, usize, String, usize) {
334 let info_c = metrics.level_info.load(Ordering::Relaxed);
335 let warn_c = metrics.level_warn.load(Ordering::Relaxed);
336 let error_c = metrics.level_error.load(Ordering::Relaxed);
337 let debug_c = metrics.level_debug.load(Ordering::Relaxed);
338 let trace_c = metrics.level_trace.load(Ordering::Relaxed);
339
340 let level_total = info_c + warn_c + error_c + debug_c + trace_c;
341
342 let info_bar = render_level_bar(info_c, level_total.max(1));
343 let info_pct = (info_c * 100).checked_div(level_total).unwrap_or(0);
344 let error_bar = render_level_bar(error_c, level_total.max(1));
345 let error_pct =
346 (error_c * 100).checked_div(level_total).unwrap_or(0);
347
348 (info_bar, info_pct, error_bar, error_pct)
349}
350
351#[allow(clippy::cast_possible_truncation)]
355pub fn render_tick(
356 metrics: &TuiMetrics,
357 last_total: &mut usize,
358 sparkline_ring: &mut [usize; SPARKLINE_RING_SIZE],
359 spark_cursor: &mut usize,
360) -> String {
361 let total = metrics.total_events.load(Ordering::Relaxed);
362 let errors = metrics.error_count.load(Ordering::Relaxed);
363 let spans = metrics.active_spans.load(Ordering::Relaxed);
364 let dropped = metrics.dropped_events.load(Ordering::Relaxed);
365
366 let diff = total.saturating_sub(*last_total);
368 *last_total = total;
369 let tps = diff * 60;
370 metrics.throughput.store(tps, Ordering::Relaxed);
371
372 let _ = metrics.peak_throughput.fetch_max(tps, Ordering::Relaxed);
374 let peak = metrics.peak_throughput.load(Ordering::Relaxed);
375
376 sparkline_ring[*spark_cursor % SPARKLINE_RING_SIZE] = tps;
378 *spark_cursor = spark_cursor.wrapping_add(1);
379
380 let now_secs = std::time::SystemTime::now()
382 .duration_since(std::time::UNIX_EPOCH)
383 .unwrap_or_default()
384 .as_secs() as usize;
385 let uptime_secs = now_secs.saturating_sub(
386 metrics.start_epoch_secs.load(Ordering::Relaxed),
387 );
388 let uptime = format_uptime(uptime_secs as u64);
389
390 let (info_bar, info_pct, error_bar, error_pct) =
392 compute_level_bars(metrics);
393
394 let fmt_line = build_fmt_line(metrics);
396
397 let sparkline = render_sparkline(
398 sparkline_ring,
399 *spark_cursor % SPARKLINE_RING_SIZE,
400 );
401
402 let total_fmt = format_with_commas(total);
403
404 let width = get_terminal_width() as usize;
405 let separator: String =
406 "\u{2500}".repeat(width.min(SPARKLINE_RING_SIZE));
407
408 let height = get_terminal_height();
409
410 format!(
411 "\x1b7\x1b[{height};1H\x1b[7A\x1b[J\
412\x1b[38;5;33m[ \x1b[1;37mRLG Liquid Glass Dashboard \x1b[0;38;5;33m]\x1b[0m\n\
413\x1b[1mErrors:\x1b[0m {errors} | \x1b[1mDropped:\x1b[0m {dropped} | \x1b[1mActive Spans:\x1b[0m {spans} | \x1b[1mThroughput:\x1b[0m {tps} ev/s\n\
414\x1b[1mPeak:\x1b[0m {peak} ev/s | \x1b[1mUptime:\x1b[0m {uptime} | \x1b[1mTotal:\x1b[0m {total_fmt}\n\
415\x1b[1mThroughput\x1b[0m {sparkline}\n\
416\x1b[1mLevels:\x1b[0m {info_bar} {info_pct}% INFO {error_bar} {error_pct}% ERROR\n\
417\x1b[1mFormats:\x1b[0m {fmt_line}\n\
418\x1b[38;5;239m{separator}\x1b[0m\x1b8"
419 )
420}
421
422pub fn spawn_tui_thread(
428 metrics: Arc<TuiMetrics>,
429 shutdown_flag: Arc<AtomicBool>,
430) {
431 #[allow(clippy::cast_possible_truncation)]
433 let start = std::time::SystemTime::now()
434 .duration_since(std::time::UNIX_EPOCH)
435 .unwrap_or_default()
436 .as_secs() as usize;
437 metrics.start_epoch_secs.store(start, Ordering::Relaxed);
438
439 thread::Builder::new()
440 .name("rlg-tui".into())
441 .spawn(move || {
442 let mut last_total: usize = 0;
443 let mut sparkline_ring = [0_usize; SPARKLINE_RING_SIZE];
444 let mut spark_cursor: usize = 0;
445
446 loop {
447 if shutdown_flag.load(Ordering::Relaxed) {
448 break;
449 }
450
451 thread::sleep(Duration::from_millis(
452 TUI_TICK_INTERVAL_MS,
453 ));
454
455 let frame = render_tick(
456 &metrics,
457 &mut last_total,
458 &mut sparkline_ring,
459 &mut spark_cursor,
460 );
461
462 let mut stdout = std::io::stdout();
463 let _ = write!(stdout, "{frame}");
464 let _ = stdout.flush();
465 }
466
467 let _ = write!(std::io::stdout(), "\x1b[r\x1b[J");
469 })
470 .expect("Failed to spawn TUI thread");
471}
472
473fn format_with_commas(n: usize) -> String {
475 let s = n.to_string();
476 let mut result = String::with_capacity(s.len() + s.len() / 3);
477 for (i, ch) in s.chars().enumerate() {
478 if i > 0 && (s.len() - i).is_multiple_of(3) {
479 result.push(',');
480 }
481 result.push(ch);
482 }
483 result
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 #[test]
491 fn test_format_with_commas_zero() {
492 assert_eq!(format_with_commas(0), "0");
493 }
494
495 #[test]
496 fn test_format_with_commas_small() {
497 assert_eq!(format_with_commas(42), "42");
498 assert_eq!(format_with_commas(999), "999");
499 }
500
501 #[test]
502 fn test_format_with_commas_thousands() {
503 assert_eq!(format_with_commas(1000), "1,000");
504 assert_eq!(format_with_commas(1_234), "1,234");
505 assert_eq!(format_with_commas(999_999), "999,999");
506 }
507
508 #[test]
509 fn test_format_with_commas_millions() {
510 assert_eq!(format_with_commas(1_000_000), "1,000,000");
511 assert_eq!(format_with_commas(1_234_567), "1,234,567");
512 }
513
514 #[test]
515 fn test_format_uptime_zero() {
516 assert_eq!(format_uptime(0), "00:00:00");
517 }
518
519 #[test]
520 fn test_format_uptime_seconds() {
521 assert_eq!(format_uptime(45), "00:00:45");
522 }
523
524 #[test]
525 fn test_format_uptime_minutes() {
526 assert_eq!(format_uptime(125), "00:02:05");
527 }
528
529 #[test]
530 fn test_format_uptime_hours() {
531 assert_eq!(format_uptime(3661), "01:01:01");
532 assert_eq!(format_uptime(86399), "23:59:59");
533 }
534
535 #[test]
536 fn test_render_level_bar_zero_total() {
537 let bar = render_level_bar(0, 0);
538 assert_eq!(bar.chars().count(), 10);
539 assert!(bar.chars().all(|c| c == '\u{2591}'));
541 }
542
543 #[test]
544 fn test_render_level_bar_full() {
545 let bar = render_level_bar(100, 100);
546 assert_eq!(bar.chars().count(), 10);
547 assert!(bar.chars().all(|c| c == '\u{2588}'));
548 }
549
550 #[test]
551 fn test_render_level_bar_half() {
552 let bar = render_level_bar(50, 100);
553 assert_eq!(bar.chars().count(), 10);
554 let filled = bar.chars().filter(|&c| c == '\u{2588}').count();
555 assert_eq!(filled, 5);
556 }
557
558 #[test]
559 fn test_render_level_bar_empty() {
560 let bar = render_level_bar(0, 100);
561 assert_eq!(bar.chars().count(), 10);
562 assert!(bar.chars().all(|c| c == '\u{2591}'));
563 }
564
565 #[test]
566 fn test_render_sparkline_empty() {
567 let ring = [0_usize; SPARKLINE_RING_SIZE];
568 let sparkline = render_sparkline(&ring, 0);
569 assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
570 assert!(sparkline.chars().all(|c| c == '\u{2581}'));
572 }
573
574 #[test]
575 fn test_render_sparkline_uniform() {
576 let ring = [100_usize; SPARKLINE_RING_SIZE];
577 let sparkline = render_sparkline(&ring, 0);
578 assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
579 }
580
581 #[test]
582 fn test_render_sparkline_varied() {
583 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
584 ring[0] = 100;
585 ring[30] = 50;
586 ring[59] = 25;
587 let sparkline = render_sparkline(&ring, 0);
588 assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
589 }
590
591 #[test]
592 fn test_render_sparkline_cursor_wrap() {
593 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
594 ring[55] = 10;
595 ring[5] = 20;
596 let sparkline = render_sparkline(&ring, 50);
597 assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
598 }
599
600 #[test]
601 fn test_get_terminal_width() {
602 let w = get_terminal_width();
604 assert!(w > 0);
605 }
606
607 #[test]
608 fn test_tui_metrics_inc_level_all_variants() {
609 let m = TuiMetrics::default();
610
611 m.inc_level(crate::log_level::LogLevel::TRACE);
612 assert_eq!(m.level_trace.load(Ordering::Relaxed), 1);
613
614 m.inc_level(crate::log_level::LogLevel::DEBUG);
615 assert_eq!(m.level_debug.load(Ordering::Relaxed), 1);
616
617 m.inc_level(crate::log_level::LogLevel::INFO);
618 assert_eq!(m.level_info.load(Ordering::Relaxed), 1);
619
620 m.inc_level(crate::log_level::LogLevel::WARN);
621 assert_eq!(m.level_warn.load(Ordering::Relaxed), 1);
622
623 m.inc_level(crate::log_level::LogLevel::ERROR);
624 assert_eq!(m.level_error.load(Ordering::Relaxed), 1);
625
626 m.inc_level(crate::log_level::LogLevel::FATAL);
627 assert_eq!(m.level_fatal.load(Ordering::Relaxed), 1);
628
629 m.inc_level(crate::log_level::LogLevel::CRITICAL);
630 assert_eq!(m.level_critical.load(Ordering::Relaxed), 1);
631
632 m.inc_level(crate::log_level::LogLevel::ALL);
634 m.inc_level(crate::log_level::LogLevel::NONE);
635 }
636
637 #[test]
638 fn test_tui_metrics_inc_format_all_variants() {
639 let m = TuiMetrics::default();
640
641 m.inc_format(crate::log_format::LogFormat::CLF);
642 assert_eq!(m.fmt_clf.load(Ordering::Relaxed), 1);
643
644 m.inc_format(crate::log_format::LogFormat::JSON);
645 assert_eq!(m.fmt_json.load(Ordering::Relaxed), 1);
646
647 m.inc_format(crate::log_format::LogFormat::CEF);
648 assert_eq!(m.fmt_cef.load(Ordering::Relaxed), 1);
649
650 m.inc_format(crate::log_format::LogFormat::ELF);
651 assert_eq!(m.fmt_elf.load(Ordering::Relaxed), 1);
652
653 m.inc_format(crate::log_format::LogFormat::W3C);
654 assert_eq!(m.fmt_w3c.load(Ordering::Relaxed), 1);
655
656 m.inc_format(crate::log_format::LogFormat::GELF);
657 assert_eq!(m.fmt_gelf.load(Ordering::Relaxed), 1);
658
659 m.inc_format(crate::log_format::LogFormat::ApacheAccessLog);
660 assert_eq!(m.fmt_apache.load(Ordering::Relaxed), 1);
661
662 m.inc_format(crate::log_format::LogFormat::Logstash);
663 assert_eq!(m.fmt_logstash.load(Ordering::Relaxed), 1);
664
665 m.inc_format(crate::log_format::LogFormat::Log4jXML);
666 assert_eq!(m.fmt_log4j.load(Ordering::Relaxed), 1);
667
668 m.inc_format(crate::log_format::LogFormat::NDJSON);
669 assert_eq!(m.fmt_ndjson.load(Ordering::Relaxed), 1);
670
671 m.inc_format(crate::log_format::LogFormat::MCP);
672 assert_eq!(m.fmt_mcp.load(Ordering::Relaxed), 1);
673
674 m.inc_format(crate::log_format::LogFormat::OTLP);
675 assert_eq!(m.fmt_otlp.load(Ordering::Relaxed), 1);
676
677 m.inc_format(crate::log_format::LogFormat::Logfmt);
678 assert_eq!(m.fmt_logfmt.load(Ordering::Relaxed), 1);
679
680 m.inc_format(crate::log_format::LogFormat::ECS);
681 assert_eq!(m.fmt_ecs.load(Ordering::Relaxed), 1);
682 }
683
684 #[test]
685 fn test_tui_metrics_peak_throughput() {
686 let m = TuiMetrics::default();
687 m.peak_throughput.store(100, Ordering::Relaxed);
688 assert_eq!(m.peak_throughput.load(Ordering::Relaxed), 100);
689 }
690
691 #[test]
692 fn test_tui_metrics_start_epoch() {
693 let m = TuiMetrics::default();
694 m.start_epoch_secs.store(1_234_567_890, Ordering::Relaxed);
695 assert_eq!(
696 m.start_epoch_secs.load(Ordering::Relaxed),
697 1_234_567_890
698 );
699 }
700
701 #[test]
702 fn test_spark_chars_length() {
703 assert_eq!(SPARK_CHARS.len(), 8);
704 }
705
706 #[test]
707 fn test_build_fmt_line_empty() {
708 let m = TuiMetrics::default();
709 let line = build_fmt_line(&m);
710 assert_eq!(line, "(none)");
711 }
712
713 #[test]
714 fn test_build_fmt_line_single() {
715 let m = TuiMetrics::default();
716 m.fmt_json.store(42, Ordering::Relaxed);
717 let line = build_fmt_line(&m);
718 assert_eq!(line, "JSON: 42");
719 }
720
721 #[test]
722 fn test_build_fmt_line_multiple() {
723 let m = TuiMetrics::default();
724 m.fmt_json.store(10, Ordering::Relaxed);
725 m.fmt_mcp.store(20, Ordering::Relaxed);
726 m.fmt_otlp.store(5, Ordering::Relaxed);
727 let line = build_fmt_line(&m);
728 assert!(line.contains("JSON: 10"));
729 assert!(line.contains("MCP: 20"));
730 assert!(line.contains("OTLP: 5"));
731 assert!(line.contains(" | "));
732 }
733
734 #[test]
735 fn test_build_fmt_line_all_formats() {
736 let m = TuiMetrics::default();
737 m.fmt_clf.store(1, Ordering::Relaxed);
738 m.fmt_json.store(2, Ordering::Relaxed);
739 m.fmt_cef.store(3, Ordering::Relaxed);
740 m.fmt_elf.store(4, Ordering::Relaxed);
741 m.fmt_w3c.store(5, Ordering::Relaxed);
742 m.fmt_gelf.store(6, Ordering::Relaxed);
743 m.fmt_apache.store(7, Ordering::Relaxed);
744 m.fmt_logstash.store(8, Ordering::Relaxed);
745 m.fmt_log4j.store(9, Ordering::Relaxed);
746 m.fmt_ndjson.store(10, Ordering::Relaxed);
747 m.fmt_mcp.store(11, Ordering::Relaxed);
748 m.fmt_otlp.store(12, Ordering::Relaxed);
749 m.fmt_logfmt.store(13, Ordering::Relaxed);
750 m.fmt_ecs.store(14, Ordering::Relaxed);
751 let line = build_fmt_line(&m);
752 assert!(line.contains("CLF: 1"));
753 assert!(line.contains("ECS: 14"));
754 }
755
756 #[test]
757 fn test_compute_level_bars_empty() {
758 let m = TuiMetrics::default();
759 let (info_bar, info_pct, error_bar, error_pct) =
760 compute_level_bars(&m);
761 assert_eq!(info_pct, 0);
762 assert_eq!(error_pct, 0);
763 assert_eq!(info_bar.chars().count(), 10);
764 assert_eq!(error_bar.chars().count(), 10);
765 }
766
767 #[test]
768 fn test_compute_level_bars_with_data() {
769 let m = TuiMetrics::default();
770 m.level_info.store(80, Ordering::Relaxed);
771 m.level_error.store(20, Ordering::Relaxed);
772 let (info_bar, info_pct, error_bar, error_pct) =
773 compute_level_bars(&m);
774 assert_eq!(info_pct, 80);
775 assert_eq!(error_pct, 20);
776 let filled =
778 info_bar.chars().filter(|&c| c == '\u{2588}').count();
779 assert_eq!(filled, 8);
780 let filled =
782 error_bar.chars().filter(|&c| c == '\u{2588}').count();
783 assert_eq!(filled, 2);
784 }
785
786 #[test]
787 fn test_compute_level_bars_all_levels() {
788 let m = TuiMetrics::default();
789 m.level_info.store(50, Ordering::Relaxed);
790 m.level_warn.store(20, Ordering::Relaxed);
791 m.level_error.store(10, Ordering::Relaxed);
792 m.level_debug.store(15, Ordering::Relaxed);
793 m.level_trace.store(5, Ordering::Relaxed);
794 let (_info_bar, info_pct, _error_bar, error_pct) =
795 compute_level_bars(&m);
796 assert_eq!(info_pct, 50);
797 assert_eq!(error_pct, 10);
798 }
799
800 #[test]
801 fn test_render_tick_basic() {
802 let m = TuiMetrics::default();
803 m.total_events.store(100, Ordering::Relaxed);
804 m.error_count.store(5, Ordering::Relaxed);
805 m.active_spans.store(2, Ordering::Relaxed);
806 m.level_info.store(80, Ordering::Relaxed);
807 m.level_error.store(20, Ordering::Relaxed);
808 m.fmt_json.store(50, Ordering::Relaxed);
809 m.fmt_mcp.store(30, Ordering::Relaxed);
810
811 #[allow(clippy::cast_possible_truncation)]
812 let now = std::time::SystemTime::now()
813 .duration_since(std::time::UNIX_EPOCH)
814 .unwrap_or_default()
815 .as_secs() as usize;
816 m.start_epoch_secs.store(now, Ordering::Relaxed);
817
818 let mut last_total = 0_usize;
819 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
820 let mut cursor = 0_usize;
821
822 let frame =
823 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
824 assert!(frame.contains("RLG Liquid Glass Dashboard"));
825 assert!(frame.contains("Errors:"));
826 assert!(frame.contains("Active Spans:"));
827 assert!(frame.contains("Throughput"));
828 assert!(frame.contains("Peak:"));
829 assert!(frame.contains("Uptime:"));
830 assert!(frame.contains("Levels:"));
831 assert!(frame.contains("Formats:"));
832 assert!(frame.contains("JSON: 50"));
833 assert!(frame.contains("MCP: 30"));
834 }
835
836 #[test]
837 fn test_render_tick_updates_state() {
838 let m = TuiMetrics::default();
839 m.total_events.store(100, Ordering::Relaxed);
840 m.start_epoch_secs.store(0, Ordering::Relaxed);
841
842 let mut last_total = 0_usize;
843 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
844 let mut cursor = 0_usize;
845
846 let _ =
847 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
848
849 assert_eq!(last_total, 100);
851 assert_eq!(cursor, 1);
853 assert_eq!(ring[0], 100 * 60); assert_eq!(m.throughput.load(Ordering::Relaxed), 100 * 60);
857 }
858
859 #[test]
860 fn test_render_tick_no_diff() {
861 let m = TuiMetrics::default();
862 m.total_events.store(50, Ordering::Relaxed);
863 m.start_epoch_secs.store(0, Ordering::Relaxed);
864
865 let mut last_total = 50_usize;
866 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
867 let mut cursor = 0_usize;
868
869 let _ =
870 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
871 assert_eq!(m.throughput.load(Ordering::Relaxed), 0);
872 }
873
874 #[test]
875 fn test_render_tick_peak_tracking() {
876 let m = TuiMetrics::default();
877 m.start_epoch_secs.store(0, Ordering::Relaxed);
878
879 let mut last_total = 0_usize;
880 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
881 let mut cursor = 0_usize;
882
883 m.total_events.store(100, Ordering::Relaxed);
885 let _ =
886 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
887 assert_eq!(m.peak_throughput.load(Ordering::Relaxed), 6000);
888
889 m.total_events.store(150, Ordering::Relaxed);
891 let _ =
892 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
893 assert_eq!(m.peak_throughput.load(Ordering::Relaxed), 6000);
895 }
896
897 #[test]
898 fn test_render_tick_no_formats() {
899 let m = TuiMetrics::default();
900 m.start_epoch_secs.store(0, Ordering::Relaxed);
901
902 let mut last_total = 0_usize;
903 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
904 let mut cursor = 0_usize;
905
906 let frame =
907 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
908 assert!(frame.contains("(none)"));
909 }
910
911 #[test]
912 fn test_get_terminal_height() {
913 let h = get_terminal_height();
914 assert!(h > 0);
915 }
916
917 #[test]
918 fn test_tui_metrics_dropped_events() {
919 let m = TuiMetrics::default();
920 assert_eq!(m.dropped_events.load(Ordering::Relaxed), 0);
921 m.inc_dropped();
922 m.inc_dropped();
923 assert_eq!(m.dropped_events.load(Ordering::Relaxed), 2);
924 }
925
926 #[test]
927 fn test_render_tick_shows_dropped() {
928 let m = TuiMetrics::default();
929 m.start_epoch_secs.store(0, Ordering::Relaxed);
930 m.dropped_events.store(42, Ordering::Relaxed);
931
932 let mut last_total = 0_usize;
933 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
934 let mut cursor = 0_usize;
935
936 let frame =
937 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
938 assert!(frame.contains("Dropped:"));
939 assert!(frame.contains("42"));
940 }
941}