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"))]
56fn 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"))]
242fn 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 = if level_total > 0 {
344 (info_c * 100) / level_total
345 } else {
346 0
347 };
348 let error_bar = render_level_bar(error_c, level_total.max(1));
349 let error_pct = if level_total > 0 {
350 (error_c * 100) / level_total
351 } else {
352 0
353 };
354
355 (info_bar, info_pct, error_bar, error_pct)
356}
357
358#[allow(clippy::cast_possible_truncation)]
362pub fn render_tick(
363 metrics: &TuiMetrics,
364 last_total: &mut usize,
365 sparkline_ring: &mut [usize; SPARKLINE_RING_SIZE],
366 spark_cursor: &mut usize,
367) -> String {
368 let total = metrics.total_events.load(Ordering::Relaxed);
369 let errors = metrics.error_count.load(Ordering::Relaxed);
370 let spans = metrics.active_spans.load(Ordering::Relaxed);
371 let dropped = metrics.dropped_events.load(Ordering::Relaxed);
372
373 let diff = total.saturating_sub(*last_total);
375 *last_total = total;
376 let tps = diff * 60;
377 metrics.throughput.store(tps, Ordering::Relaxed);
378
379 let _ = metrics.peak_throughput.fetch_max(tps, Ordering::Relaxed);
381 let peak = metrics.peak_throughput.load(Ordering::Relaxed);
382
383 sparkline_ring[*spark_cursor % SPARKLINE_RING_SIZE] = tps;
385 *spark_cursor = spark_cursor.wrapping_add(1);
386
387 let now_secs = std::time::SystemTime::now()
389 .duration_since(std::time::UNIX_EPOCH)
390 .unwrap_or_default()
391 .as_secs() as usize;
392 let uptime_secs = now_secs.saturating_sub(
393 metrics.start_epoch_secs.load(Ordering::Relaxed),
394 );
395 let uptime = format_uptime(uptime_secs as u64);
396
397 let (info_bar, info_pct, error_bar, error_pct) =
399 compute_level_bars(metrics);
400
401 let fmt_line = build_fmt_line(metrics);
403
404 let sparkline = render_sparkline(
405 sparkline_ring,
406 *spark_cursor % SPARKLINE_RING_SIZE,
407 );
408
409 let total_fmt = format_with_commas(total);
410
411 let width = get_terminal_width() as usize;
412 let separator: String =
413 "\u{2500}".repeat(width.min(SPARKLINE_RING_SIZE));
414
415 let height = get_terminal_height();
416
417 format!(
418 "\x1b7\x1b[{height};1H\x1b[7A\x1b[J\
419\x1b[38;5;33m[ \x1b[1;37mRLG Liquid Glass Dashboard \x1b[0;38;5;33m]\x1b[0m\n\
420\x1b[1mErrors:\x1b[0m {errors} | \x1b[1mDropped:\x1b[0m {dropped} | \x1b[1mActive Spans:\x1b[0m {spans} | \x1b[1mThroughput:\x1b[0m {tps} ev/s\n\
421\x1b[1mPeak:\x1b[0m {peak} ev/s | \x1b[1mUptime:\x1b[0m {uptime} | \x1b[1mTotal:\x1b[0m {total_fmt}\n\
422\x1b[1mThroughput\x1b[0m {sparkline}\n\
423\x1b[1mLevels:\x1b[0m {info_bar} {info_pct}% INFO {error_bar} {error_pct}% ERROR\n\
424\x1b[1mFormats:\x1b[0m {fmt_line}\n\
425\x1b[38;5;239m{separator}\x1b[0m\x1b8"
426 )
427}
428
429pub fn spawn_tui_thread(
435 metrics: Arc<TuiMetrics>,
436 shutdown_flag: Arc<AtomicBool>,
437) {
438 #[allow(clippy::cast_possible_truncation)]
440 let start = std::time::SystemTime::now()
441 .duration_since(std::time::UNIX_EPOCH)
442 .unwrap_or_default()
443 .as_secs() as usize;
444 metrics.start_epoch_secs.store(start, Ordering::Relaxed);
445
446 thread::Builder::new()
447 .name("rlg-tui".into())
448 .spawn(move || {
449 let mut last_total: usize = 0;
450 let mut sparkline_ring = [0_usize; SPARKLINE_RING_SIZE];
451 let mut spark_cursor: usize = 0;
452
453 loop {
454 if shutdown_flag.load(Ordering::Relaxed) {
455 break;
456 }
457
458 thread::sleep(Duration::from_millis(
459 TUI_TICK_INTERVAL_MS,
460 ));
461
462 let frame = render_tick(
463 &metrics,
464 &mut last_total,
465 &mut sparkline_ring,
466 &mut spark_cursor,
467 );
468
469 let mut stdout = std::io::stdout();
470 let _ = write!(stdout, "{frame}");
471 let _ = stdout.flush();
472 }
473
474 let _ = write!(std::io::stdout(), "\x1b[r\x1b[J");
476 })
477 .expect("Failed to spawn TUI thread");
478}
479
480fn format_with_commas(n: usize) -> String {
482 let s = n.to_string();
483 let mut result = String::with_capacity(s.len() + s.len() / 3);
484 for (i, ch) in s.chars().enumerate() {
485 if i > 0 && (s.len() - i).is_multiple_of(3) {
486 result.push(',');
487 }
488 result.push(ch);
489 }
490 result
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[test]
498 fn test_format_with_commas_zero() {
499 assert_eq!(format_with_commas(0), "0");
500 }
501
502 #[test]
503 fn test_format_with_commas_small() {
504 assert_eq!(format_with_commas(42), "42");
505 assert_eq!(format_with_commas(999), "999");
506 }
507
508 #[test]
509 fn test_format_with_commas_thousands() {
510 assert_eq!(format_with_commas(1000), "1,000");
511 assert_eq!(format_with_commas(1_234), "1,234");
512 assert_eq!(format_with_commas(999_999), "999,999");
513 }
514
515 #[test]
516 fn test_format_with_commas_millions() {
517 assert_eq!(format_with_commas(1_000_000), "1,000,000");
518 assert_eq!(format_with_commas(1_234_567), "1,234,567");
519 }
520
521 #[test]
522 fn test_format_uptime_zero() {
523 assert_eq!(format_uptime(0), "00:00:00");
524 }
525
526 #[test]
527 fn test_format_uptime_seconds() {
528 assert_eq!(format_uptime(45), "00:00:45");
529 }
530
531 #[test]
532 fn test_format_uptime_minutes() {
533 assert_eq!(format_uptime(125), "00:02:05");
534 }
535
536 #[test]
537 fn test_format_uptime_hours() {
538 assert_eq!(format_uptime(3661), "01:01:01");
539 assert_eq!(format_uptime(86399), "23:59:59");
540 }
541
542 #[test]
543 fn test_render_level_bar_zero_total() {
544 let bar = render_level_bar(0, 0);
545 assert_eq!(bar.chars().count(), 10);
546 assert!(bar.chars().all(|c| c == '\u{2591}'));
548 }
549
550 #[test]
551 fn test_render_level_bar_full() {
552 let bar = render_level_bar(100, 100);
553 assert_eq!(bar.chars().count(), 10);
554 assert!(bar.chars().all(|c| c == '\u{2588}'));
555 }
556
557 #[test]
558 fn test_render_level_bar_half() {
559 let bar = render_level_bar(50, 100);
560 assert_eq!(bar.chars().count(), 10);
561 let filled = bar.chars().filter(|&c| c == '\u{2588}').count();
562 assert_eq!(filled, 5);
563 }
564
565 #[test]
566 fn test_render_level_bar_empty() {
567 let bar = render_level_bar(0, 100);
568 assert_eq!(bar.chars().count(), 10);
569 assert!(bar.chars().all(|c| c == '\u{2591}'));
570 }
571
572 #[test]
573 fn test_render_sparkline_empty() {
574 let ring = [0_usize; SPARKLINE_RING_SIZE];
575 let sparkline = render_sparkline(&ring, 0);
576 assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
577 assert!(sparkline.chars().all(|c| c == '\u{2581}'));
579 }
580
581 #[test]
582 fn test_render_sparkline_uniform() {
583 let ring = [100_usize; SPARKLINE_RING_SIZE];
584 let sparkline = render_sparkline(&ring, 0);
585 assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
586 }
587
588 #[test]
589 fn test_render_sparkline_varied() {
590 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
591 ring[0] = 100;
592 ring[30] = 50;
593 ring[59] = 25;
594 let sparkline = render_sparkline(&ring, 0);
595 assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
596 }
597
598 #[test]
599 fn test_render_sparkline_cursor_wrap() {
600 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
601 ring[55] = 10;
602 ring[5] = 20;
603 let sparkline = render_sparkline(&ring, 50);
604 assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
605 }
606
607 #[test]
608 fn test_get_terminal_width() {
609 let w = get_terminal_width();
611 assert!(w > 0);
612 }
613
614 #[test]
615 fn test_tui_metrics_inc_level_all_variants() {
616 let m = TuiMetrics::default();
617
618 m.inc_level(crate::log_level::LogLevel::TRACE);
619 assert_eq!(m.level_trace.load(Ordering::Relaxed), 1);
620
621 m.inc_level(crate::log_level::LogLevel::DEBUG);
622 assert_eq!(m.level_debug.load(Ordering::Relaxed), 1);
623
624 m.inc_level(crate::log_level::LogLevel::INFO);
625 assert_eq!(m.level_info.load(Ordering::Relaxed), 1);
626
627 m.inc_level(crate::log_level::LogLevel::WARN);
628 assert_eq!(m.level_warn.load(Ordering::Relaxed), 1);
629
630 m.inc_level(crate::log_level::LogLevel::ERROR);
631 assert_eq!(m.level_error.load(Ordering::Relaxed), 1);
632
633 m.inc_level(crate::log_level::LogLevel::FATAL);
634 assert_eq!(m.level_fatal.load(Ordering::Relaxed), 1);
635
636 m.inc_level(crate::log_level::LogLevel::CRITICAL);
637 assert_eq!(m.level_critical.load(Ordering::Relaxed), 1);
638
639 m.inc_level(crate::log_level::LogLevel::ALL);
641 m.inc_level(crate::log_level::LogLevel::NONE);
642 }
643
644 #[test]
645 fn test_tui_metrics_inc_format_all_variants() {
646 let m = TuiMetrics::default();
647
648 m.inc_format(crate::log_format::LogFormat::CLF);
649 assert_eq!(m.fmt_clf.load(Ordering::Relaxed), 1);
650
651 m.inc_format(crate::log_format::LogFormat::JSON);
652 assert_eq!(m.fmt_json.load(Ordering::Relaxed), 1);
653
654 m.inc_format(crate::log_format::LogFormat::CEF);
655 assert_eq!(m.fmt_cef.load(Ordering::Relaxed), 1);
656
657 m.inc_format(crate::log_format::LogFormat::ELF);
658 assert_eq!(m.fmt_elf.load(Ordering::Relaxed), 1);
659
660 m.inc_format(crate::log_format::LogFormat::W3C);
661 assert_eq!(m.fmt_w3c.load(Ordering::Relaxed), 1);
662
663 m.inc_format(crate::log_format::LogFormat::GELF);
664 assert_eq!(m.fmt_gelf.load(Ordering::Relaxed), 1);
665
666 m.inc_format(crate::log_format::LogFormat::ApacheAccessLog);
667 assert_eq!(m.fmt_apache.load(Ordering::Relaxed), 1);
668
669 m.inc_format(crate::log_format::LogFormat::Logstash);
670 assert_eq!(m.fmt_logstash.load(Ordering::Relaxed), 1);
671
672 m.inc_format(crate::log_format::LogFormat::Log4jXML);
673 assert_eq!(m.fmt_log4j.load(Ordering::Relaxed), 1);
674
675 m.inc_format(crate::log_format::LogFormat::NDJSON);
676 assert_eq!(m.fmt_ndjson.load(Ordering::Relaxed), 1);
677
678 m.inc_format(crate::log_format::LogFormat::MCP);
679 assert_eq!(m.fmt_mcp.load(Ordering::Relaxed), 1);
680
681 m.inc_format(crate::log_format::LogFormat::OTLP);
682 assert_eq!(m.fmt_otlp.load(Ordering::Relaxed), 1);
683
684 m.inc_format(crate::log_format::LogFormat::Logfmt);
685 assert_eq!(m.fmt_logfmt.load(Ordering::Relaxed), 1);
686
687 m.inc_format(crate::log_format::LogFormat::ECS);
688 assert_eq!(m.fmt_ecs.load(Ordering::Relaxed), 1);
689 }
690
691 #[test]
692 fn test_tui_metrics_peak_throughput() {
693 let m = TuiMetrics::default();
694 m.peak_throughput.store(100, Ordering::Relaxed);
695 assert_eq!(m.peak_throughput.load(Ordering::Relaxed), 100);
696 }
697
698 #[test]
699 fn test_tui_metrics_start_epoch() {
700 let m = TuiMetrics::default();
701 m.start_epoch_secs.store(1_234_567_890, Ordering::Relaxed);
702 assert_eq!(
703 m.start_epoch_secs.load(Ordering::Relaxed),
704 1_234_567_890
705 );
706 }
707
708 #[test]
709 fn test_spark_chars_length() {
710 assert_eq!(SPARK_CHARS.len(), 8);
711 }
712
713 #[test]
714 fn test_build_fmt_line_empty() {
715 let m = TuiMetrics::default();
716 let line = build_fmt_line(&m);
717 assert_eq!(line, "(none)");
718 }
719
720 #[test]
721 fn test_build_fmt_line_single() {
722 let m = TuiMetrics::default();
723 m.fmt_json.store(42, Ordering::Relaxed);
724 let line = build_fmt_line(&m);
725 assert_eq!(line, "JSON: 42");
726 }
727
728 #[test]
729 fn test_build_fmt_line_multiple() {
730 let m = TuiMetrics::default();
731 m.fmt_json.store(10, Ordering::Relaxed);
732 m.fmt_mcp.store(20, Ordering::Relaxed);
733 m.fmt_otlp.store(5, Ordering::Relaxed);
734 let line = build_fmt_line(&m);
735 assert!(line.contains("JSON: 10"));
736 assert!(line.contains("MCP: 20"));
737 assert!(line.contains("OTLP: 5"));
738 assert!(line.contains(" | "));
739 }
740
741 #[test]
742 fn test_build_fmt_line_all_formats() {
743 let m = TuiMetrics::default();
744 m.fmt_clf.store(1, Ordering::Relaxed);
745 m.fmt_json.store(2, Ordering::Relaxed);
746 m.fmt_cef.store(3, Ordering::Relaxed);
747 m.fmt_elf.store(4, Ordering::Relaxed);
748 m.fmt_w3c.store(5, Ordering::Relaxed);
749 m.fmt_gelf.store(6, Ordering::Relaxed);
750 m.fmt_apache.store(7, Ordering::Relaxed);
751 m.fmt_logstash.store(8, Ordering::Relaxed);
752 m.fmt_log4j.store(9, Ordering::Relaxed);
753 m.fmt_ndjson.store(10, Ordering::Relaxed);
754 m.fmt_mcp.store(11, Ordering::Relaxed);
755 m.fmt_otlp.store(12, Ordering::Relaxed);
756 m.fmt_logfmt.store(13, Ordering::Relaxed);
757 m.fmt_ecs.store(14, Ordering::Relaxed);
758 let line = build_fmt_line(&m);
759 assert!(line.contains("CLF: 1"));
760 assert!(line.contains("ECS: 14"));
761 }
762
763 #[test]
764 fn test_compute_level_bars_empty() {
765 let m = TuiMetrics::default();
766 let (info_bar, info_pct, error_bar, error_pct) =
767 compute_level_bars(&m);
768 assert_eq!(info_pct, 0);
769 assert_eq!(error_pct, 0);
770 assert_eq!(info_bar.chars().count(), 10);
771 assert_eq!(error_bar.chars().count(), 10);
772 }
773
774 #[test]
775 fn test_compute_level_bars_with_data() {
776 let m = TuiMetrics::default();
777 m.level_info.store(80, Ordering::Relaxed);
778 m.level_error.store(20, Ordering::Relaxed);
779 let (info_bar, info_pct, error_bar, error_pct) =
780 compute_level_bars(&m);
781 assert_eq!(info_pct, 80);
782 assert_eq!(error_pct, 20);
783 let filled =
785 info_bar.chars().filter(|&c| c == '\u{2588}').count();
786 assert_eq!(filled, 8);
787 let filled =
789 error_bar.chars().filter(|&c| c == '\u{2588}').count();
790 assert_eq!(filled, 2);
791 }
792
793 #[test]
794 fn test_compute_level_bars_all_levels() {
795 let m = TuiMetrics::default();
796 m.level_info.store(50, Ordering::Relaxed);
797 m.level_warn.store(20, Ordering::Relaxed);
798 m.level_error.store(10, Ordering::Relaxed);
799 m.level_debug.store(15, Ordering::Relaxed);
800 m.level_trace.store(5, Ordering::Relaxed);
801 let (_info_bar, info_pct, _error_bar, error_pct) =
802 compute_level_bars(&m);
803 assert_eq!(info_pct, 50);
804 assert_eq!(error_pct, 10);
805 }
806
807 #[test]
808 fn test_render_tick_basic() {
809 let m = TuiMetrics::default();
810 m.total_events.store(100, Ordering::Relaxed);
811 m.error_count.store(5, Ordering::Relaxed);
812 m.active_spans.store(2, Ordering::Relaxed);
813 m.level_info.store(80, Ordering::Relaxed);
814 m.level_error.store(20, Ordering::Relaxed);
815 m.fmt_json.store(50, Ordering::Relaxed);
816 m.fmt_mcp.store(30, Ordering::Relaxed);
817
818 #[allow(clippy::cast_possible_truncation)]
819 let now = std::time::SystemTime::now()
820 .duration_since(std::time::UNIX_EPOCH)
821 .unwrap_or_default()
822 .as_secs() as usize;
823 m.start_epoch_secs.store(now, Ordering::Relaxed);
824
825 let mut last_total = 0_usize;
826 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
827 let mut cursor = 0_usize;
828
829 let frame =
830 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
831 assert!(frame.contains("RLG Liquid Glass Dashboard"));
832 assert!(frame.contains("Errors:"));
833 assert!(frame.contains("Active Spans:"));
834 assert!(frame.contains("Throughput"));
835 assert!(frame.contains("Peak:"));
836 assert!(frame.contains("Uptime:"));
837 assert!(frame.contains("Levels:"));
838 assert!(frame.contains("Formats:"));
839 assert!(frame.contains("JSON: 50"));
840 assert!(frame.contains("MCP: 30"));
841 }
842
843 #[test]
844 fn test_render_tick_updates_state() {
845 let m = TuiMetrics::default();
846 m.total_events.store(100, Ordering::Relaxed);
847 m.start_epoch_secs.store(0, Ordering::Relaxed);
848
849 let mut last_total = 0_usize;
850 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
851 let mut cursor = 0_usize;
852
853 let _ =
854 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
855
856 assert_eq!(last_total, 100);
858 assert_eq!(cursor, 1);
860 assert_eq!(ring[0], 100 * 60); assert_eq!(m.throughput.load(Ordering::Relaxed), 100 * 60);
864 }
865
866 #[test]
867 fn test_render_tick_no_diff() {
868 let m = TuiMetrics::default();
869 m.total_events.store(50, Ordering::Relaxed);
870 m.start_epoch_secs.store(0, Ordering::Relaxed);
871
872 let mut last_total = 50_usize;
873 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
874 let mut cursor = 0_usize;
875
876 let _ =
877 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
878 assert_eq!(m.throughput.load(Ordering::Relaxed), 0);
879 }
880
881 #[test]
882 fn test_render_tick_peak_tracking() {
883 let m = TuiMetrics::default();
884 m.start_epoch_secs.store(0, Ordering::Relaxed);
885
886 let mut last_total = 0_usize;
887 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
888 let mut cursor = 0_usize;
889
890 m.total_events.store(100, Ordering::Relaxed);
892 let _ =
893 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
894 assert_eq!(m.peak_throughput.load(Ordering::Relaxed), 6000);
895
896 m.total_events.store(150, Ordering::Relaxed);
898 let _ =
899 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
900 assert_eq!(m.peak_throughput.load(Ordering::Relaxed), 6000);
902 }
903
904 #[test]
905 fn test_render_tick_no_formats() {
906 let m = TuiMetrics::default();
907 m.start_epoch_secs.store(0, Ordering::Relaxed);
908
909 let mut last_total = 0_usize;
910 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
911 let mut cursor = 0_usize;
912
913 let frame =
914 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
915 assert!(frame.contains("(none)"));
916 }
917
918 #[test]
919 fn test_get_terminal_height() {
920 let h = get_terminal_height();
921 assert!(h > 0);
922 }
923
924 #[test]
925 fn test_tui_metrics_dropped_events() {
926 let m = TuiMetrics::default();
927 assert_eq!(m.dropped_events.load(Ordering::Relaxed), 0);
928 m.inc_dropped();
929 m.inc_dropped();
930 assert_eq!(m.dropped_events.load(Ordering::Relaxed), 2);
931 }
932
933 #[test]
934 fn test_render_tick_shows_dropped() {
935 let m = TuiMetrics::default();
936 m.start_epoch_secs.store(0, Ordering::Relaxed);
937 m.dropped_events.store(42, Ordering::Relaxed);
938
939 let mut last_total = 0_usize;
940 let mut ring = [0_usize; SPARKLINE_RING_SIZE];
941 let mut cursor = 0_usize;
942
943 let frame =
944 render_tick(&m, &mut last_total, &mut ring, &mut cursor);
945 assert!(frame.contains("Dropped:"));
946 assert!(frame.contains("42"));
947 }
948}