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 let counter = match format {
185 LogFormat::CLF => &self.fmt_clf,
186 LogFormat::JSON => &self.fmt_json,
187 LogFormat::CEF => &self.fmt_cef,
188 LogFormat::ELF => &self.fmt_elf,
189 LogFormat::W3C => &self.fmt_w3c,
190 LogFormat::GELF => &self.fmt_gelf,
191 LogFormat::ApacheAccessLog => &self.fmt_apache,
192 LogFormat::Logstash => &self.fmt_logstash,
193 LogFormat::Log4jXML => &self.fmt_log4j,
194 LogFormat::NDJSON => &self.fmt_ndjson,
195 LogFormat::MCP => &self.fmt_mcp,
196 LogFormat::OTLP => &self.fmt_otlp,
197 LogFormat::Logfmt => &self.fmt_logfmt,
198 LogFormat::ECS => &self.fmt_ecs,
199 };
200 let _ = counter.fetch_add(1, Ordering::Relaxed);
201 }
202}
203
204#[cfg(feature = "tui")]
206fn get_terminal_width() -> u16 {
207 terminal_size::terminal_size().map_or(
208 DEFAULT_TERMINAL_WIDTH,
209 |(terminal_size::Width(w), _)| w,
210 )
211}
212
213#[cfg(not(feature = "tui"))]
215const fn get_terminal_width() -> u16 {
216 DEFAULT_TERMINAL_WIDTH
217}
218
219const SPARK_CHARS: [char; 8] = [
221 '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}',
222 '\u{2586}', '\u{2587}', '\u{2588}',
223];
224
225fn render_sparkline(
227 ring: &[usize; SPARKLINE_RING_SIZE],
228 cursor: usize,
229) -> String {
230 let max_val = ring.iter().copied().max().unwrap_or(1).max(1);
231 let mut out = String::with_capacity(SPARKLINE_RING_SIZE);
232 for i in 0..SPARKLINE_RING_SIZE {
233 let idx = (cursor + i) % SPARKLINE_RING_SIZE;
234 let scaled = (ring[idx] * 7) / max_val;
235 out.push(SPARK_CHARS[scaled.min(7)]);
236 }
237 out
238}
239
240fn render_level_bar(count: usize, total: usize) -> String {
242 if total == 0 {
243 return "\u{2591}".repeat(LEVEL_BAR_WIDTH);
244 }
245 let filled =
246 ((count * LEVEL_BAR_WIDTH) / total).min(LEVEL_BAR_WIDTH);
247 let mut bar = String::with_capacity(LEVEL_BAR_WIDTH * 3);
248 for _ in 0..filled {
249 bar.push('\u{2588}');
250 }
251 for _ in filled..LEVEL_BAR_WIDTH {
252 bar.push('\u{2591}');
253 }
254 bar
255}
256
257fn format_uptime(secs: u64) -> String {
259 let h = secs / 3600;
260 let m = (secs % 3600) / 60;
261 let s = secs % 60;
262 format!("{h:02}:{m:02}:{s:02}")
263}
264
265pub fn build_fmt_line(metrics: &TuiMetrics) -> String {
267 let fmt_counts: Vec<(&str, usize)> = [
268 ("CLF", metrics.fmt_clf.load(Ordering::Relaxed)),
269 ("JSON", metrics.fmt_json.load(Ordering::Relaxed)),
270 ("CEF", metrics.fmt_cef.load(Ordering::Relaxed)),
271 ("ELF", metrics.fmt_elf.load(Ordering::Relaxed)),
272 ("W3C", metrics.fmt_w3c.load(Ordering::Relaxed)),
273 ("GELF", metrics.fmt_gelf.load(Ordering::Relaxed)),
274 ("Apache", metrics.fmt_apache.load(Ordering::Relaxed)),
275 ("Logstash", metrics.fmt_logstash.load(Ordering::Relaxed)),
276 ("Log4j", metrics.fmt_log4j.load(Ordering::Relaxed)),
277 ("NDJSON", metrics.fmt_ndjson.load(Ordering::Relaxed)),
278 ("MCP", metrics.fmt_mcp.load(Ordering::Relaxed)),
279 ("OTLP", metrics.fmt_otlp.load(Ordering::Relaxed)),
280 ("Logfmt", metrics.fmt_logfmt.load(Ordering::Relaxed)),
281 ("ECS", metrics.fmt_ecs.load(Ordering::Relaxed)),
282 ]
283 .into_iter()
284 .filter(|(_, c)| *c > 0)
285 .collect();
286
287 let mut fmt_line = String::new();
288 for (i, (name, count)) in fmt_counts.iter().enumerate() {
289 use std::fmt::Write as _;
290 if i > 0 {
291 fmt_line.push_str(" | ");
292 }
293 let _ = write!(fmt_line, "{name}: {count}");
294 }
295 if fmt_line.is_empty() {
296 fmt_line.push_str("(none)");
297 }
298 fmt_line
299}
300
301pub fn compute_level_bars(
305 metrics: &TuiMetrics,
306) -> (String, usize, String, usize) {
307 let info_c = metrics.level_info.load(Ordering::Relaxed);
308 let warn_c = metrics.level_warn.load(Ordering::Relaxed);
309 let error_c = metrics.level_error.load(Ordering::Relaxed);
310 let debug_c = metrics.level_debug.load(Ordering::Relaxed);
311 let trace_c = metrics.level_trace.load(Ordering::Relaxed);
312
313 let level_total = info_c + warn_c + error_c + debug_c + trace_c;
314
315 let info_bar = render_level_bar(info_c, level_total.max(1));
316 let info_pct = (info_c * 100).checked_div(level_total).unwrap_or(0);
317 let error_bar = render_level_bar(error_c, level_total.max(1));
318 let error_pct =
319 (error_c * 100).checked_div(level_total).unwrap_or(0);
320
321 (info_bar, info_pct, error_bar, error_pct)
322}
323
324pub fn render_tick(
328 metrics: &TuiMetrics,
329 last_total: &mut usize,
330 sparkline_ring: &mut [usize; SPARKLINE_RING_SIZE],
331 spark_cursor: &mut usize,
332) -> String {
333 let total = metrics.total_events.load(Ordering::Relaxed);
334 let errors = metrics.error_count.load(Ordering::Relaxed);
335 let spans = metrics.active_spans.load(Ordering::Relaxed);
336 let dropped = metrics.dropped_events.load(Ordering::Relaxed);
337 let (tps, peak) = update_throughput(
338 metrics,
339 total,
340 last_total,
341 sparkline_ring,
342 spark_cursor,
343 );
344 let uptime = uptime(metrics);
345
346 let (info_bar, info_pct, error_bar, error_pct) =
348 compute_level_bars(metrics);
349
350 let fmt_line = build_fmt_line(metrics);
352
353 let sparkline = render_sparkline(
354 sparkline_ring,
355 *spark_cursor % SPARKLINE_RING_SIZE,
356 );
357
358 let total_fmt = format_with_commas(total);
359
360 let width = get_terminal_width() as usize;
361 let separator: String =
362 "\u{2500}".repeat(width.min(SPARKLINE_RING_SIZE));
363
364 let height = get_terminal_height();
365
366 format!(
367 "\x1b7\x1b[{height};1H\x1b[7A\x1b[J\
368\x1b[38;5;33m[ \x1b[1;37mRLG Liquid Glass Dashboard \x1b[0;38;5;33m]\x1b[0m\n\
369\x1b[1mErrors:\x1b[0m {errors} | \x1b[1mDropped:\x1b[0m {dropped} | \x1b[1mActive Spans:\x1b[0m {spans} | \x1b[1mThroughput:\x1b[0m {tps} ev/s\n\
370\x1b[1mPeak:\x1b[0m {peak} ev/s | \x1b[1mUptime:\x1b[0m {uptime} | \x1b[1mTotal:\x1b[0m {total_fmt}\n\
371\x1b[1mThroughput\x1b[0m {sparkline}\n\
372\x1b[1mLevels:\x1b[0m {info_bar} {info_pct}% INFO {error_bar} {error_pct}% ERROR\n\
373\x1b[1mFormats:\x1b[0m {fmt_line}\n\
374\x1b[38;5;239m{separator}\x1b[0m\x1b8"
375 )
376}
377
378fn update_throughput(
382 metrics: &TuiMetrics,
383 total: usize,
384 last_total: &mut usize,
385 sparkline_ring: &mut [usize; SPARKLINE_RING_SIZE],
386 spark_cursor: &mut usize,
387) -> (usize, usize) {
388 let diff = total.saturating_sub(*last_total);
389 *last_total = total;
390 let tps = diff * 60;
391 metrics.throughput.store(tps, Ordering::Relaxed);
392 let _ = metrics.peak_throughput.fetch_max(tps, Ordering::Relaxed);
393 let peak = metrics.peak_throughput.load(Ordering::Relaxed);
394 sparkline_ring[*spark_cursor % SPARKLINE_RING_SIZE] = tps;
395 *spark_cursor = spark_cursor.wrapping_add(1);
396 (tps, peak)
397}
398
399fn uptime(metrics: &TuiMetrics) -> String {
401 let now_secs = std::time::SystemTime::now()
402 .duration_since(std::time::UNIX_EPOCH)
403 .unwrap_or_default()
404 .as_secs();
405 let start =
406 u64::try_from(metrics.start_epoch_secs.load(Ordering::Relaxed))
407 .unwrap_or(u64::MAX);
408 format_uptime(now_secs.saturating_sub(start))
409}
410
411pub fn spawn_tui_thread(
417 metrics: Arc<TuiMetrics>,
418 shutdown_flag: Arc<AtomicBool>,
419) {
420 #[allow(clippy::cast_possible_truncation)]
422 let start = std::time::SystemTime::now()
423 .duration_since(std::time::UNIX_EPOCH)
424 .unwrap_or_default()
425 .as_secs() as usize;
426 metrics.start_epoch_secs.store(start, Ordering::Relaxed);
427
428 thread::Builder::new()
429 .name("rlg-tui".into())
430 .spawn(move || {
431 let mut last_total: usize = 0;
432 let mut sparkline_ring = [0_usize; SPARKLINE_RING_SIZE];
433 let mut spark_cursor: usize = 0;
434
435 loop {
436 if shutdown_flag.load(Ordering::Relaxed) {
437 break;
438 }
439
440 thread::sleep(Duration::from_millis(
441 TUI_TICK_INTERVAL_MS,
442 ));
443
444 let frame = render_tick(
445 &metrics,
446 &mut last_total,
447 &mut sparkline_ring,
448 &mut spark_cursor,
449 );
450
451 let mut stdout = std::io::stdout();
452 let _ = write!(stdout, "{frame}");
453 let _ = stdout.flush();
454 }
455
456 let _ = write!(std::io::stdout(), "\x1b[r\x1b[J");
458 })
459 .expect("Failed to spawn TUI thread");
460}
461
462fn format_with_commas(n: usize) -> String {
464 let s = n.to_string();
465 let mut result = String::with_capacity(s.len() + s.len() / 3);
466 for (i, ch) in s.chars().enumerate() {
467 if i > 0 && (s.len() - i).is_multiple_of(3) {
468 result.push(',');
469 }
470 result.push(ch);
471 }
472 result
473}
474
475#[cfg(test)]
476mod tests;