Skip to main content

rlg/
tui.rs

1// tui.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-License-Identifier: MIT
5
6//! Opt-in terminal dashboard for live observability metrics.
7//!
8//! Set `RLG_TUI=1` to spawn a background render thread that paints a
9//! non-clobbering sparkline dashboard at ~60 FPS. Enable the `tui` feature
10//! for automatic terminal size detection; otherwise falls back to 80x24.
11
12use std::io::Write;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
15use std::thread;
16use std::time::Duration;
17
18/// Number of throughput samples in the sparkline ring buffer (one per tick).
19const SPARKLINE_RING_SIZE: usize = 60;
20
21/// TUI render interval in milliseconds (~60 FPS).
22const TUI_TICK_INTERVAL_MS: u64 = 16;
23
24/// Default terminal height when detection fails.
25const DEFAULT_TERMINAL_HEIGHT: u16 = 24;
26
27/// Default terminal width when detection fails.
28const DEFAULT_TERMINAL_WIDTH: u16 = 80;
29
30/// Width of level-distribution bar charts (in block characters).
31const LEVEL_BAR_WIDTH: usize = 10;
32
33#[cfg(all(not(windows), feature = "tui"))]
34/// Returns the terminal height for the given handle, or 24 as fallback.
35///
36/// # Panics
37///
38/// This function does not panic.
39#[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/// Atomic counters for the TUI dashboard. Cache-line aligned (`repr(align(64))`).
61#[repr(align(64))]
62#[derive(Debug, Default)]
63pub struct TuiMetrics {
64    /// Total number of log events ingested.
65    pub total_events: AtomicUsize,
66    /// Number of error/fatal events.
67    pub error_count: AtomicUsize,
68    /// Number of active spans (OpenTelemetry style).
69    pub active_spans: AtomicUsize,
70    /// Calculated events per second.
71    pub throughput: AtomicUsize,
72    /// Peak throughput (events per second).
73    pub peak_throughput: AtomicUsize,
74    /// Engine start time (epoch seconds).
75    pub start_epoch_secs: AtomicUsize,
76
77    // Per-level counters
78    /// TRACE-level event count.
79    pub level_trace: AtomicUsize,
80    /// DEBUG-level event count.
81    pub level_debug: AtomicUsize,
82    /// INFO-level event count.
83    pub level_info: AtomicUsize,
84    /// WARN-level event count.
85    pub level_warn: AtomicUsize,
86    /// ERROR-level event count.
87    pub level_error: AtomicUsize,
88    /// FATAL-level event count.
89    pub level_fatal: AtomicUsize,
90    /// CRITICAL-level event count.
91    pub level_critical: AtomicUsize,
92    /// Number of events dropped due to full ring buffer.
93    pub dropped_events: AtomicUsize,
94
95    // Per-format counters
96    /// CLF format count.
97    pub fmt_clf: AtomicUsize,
98    /// JSON format count.
99    pub fmt_json: AtomicUsize,
100    /// CEF format count.
101    pub fmt_cef: AtomicUsize,
102    /// ELF format count.
103    pub fmt_elf: AtomicUsize,
104    /// W3C format count.
105    pub fmt_w3c: AtomicUsize,
106    /// GELF format count.
107    pub fmt_gelf: AtomicUsize,
108    /// Apache Access Log format count.
109    pub fmt_apache: AtomicUsize,
110    /// Logstash format count.
111    pub fmt_logstash: AtomicUsize,
112    /// Log4j XML format count.
113    pub fmt_log4j: AtomicUsize,
114    /// NDJSON format count.
115    pub fmt_ndjson: AtomicUsize,
116    /// MCP format count.
117    pub fmt_mcp: AtomicUsize,
118    /// OTLP format count.
119    pub fmt_otlp: AtomicUsize,
120    /// Logfmt format count.
121    pub fmt_logfmt: AtomicUsize,
122    /// ECS format count.
123    pub fmt_ecs: AtomicUsize,
124}
125
126impl TuiMetrics {
127    /// Increments the total event count.
128    pub fn inc_events(&self) {
129        self.total_events.fetch_add(1, Ordering::Relaxed);
130    }
131
132    /// Increments the error count.
133    pub fn inc_errors(&self) {
134        self.error_count.fetch_add(1, Ordering::Relaxed);
135    }
136
137    /// Increments active spans.
138    pub fn inc_spans(&self) {
139        self.active_spans.fetch_add(1, Ordering::Relaxed);
140    }
141
142    /// Decrements active spans.
143    pub fn dec_spans(&self) {
144        self.active_spans.fetch_sub(1, Ordering::Relaxed);
145    }
146
147    /// Increments the dropped event count.
148    pub fn inc_dropped(&self) {
149        self.dropped_events.fetch_add(1, Ordering::Relaxed);
150    }
151
152    /// Increments the counter for the given log level.
153    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    /// Increments the counter for the given log format.
182    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/// Returns the terminal width, or `DEFAULT_TERMINAL_WIDTH` as fallback.
205#[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/// Returns the default terminal width when the `tui` feature is disabled.
214#[cfg(not(feature = "tui"))]
215const fn get_terminal_width() -> u16 {
216    DEFAULT_TERMINAL_WIDTH
217}
218
219/// Sparkline characters indexed by intensity (0..=7).
220const SPARK_CHARS: [char; 8] = [
221    '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}',
222    '\u{2586}', '\u{2587}', '\u{2588}',
223];
224
225/// Renders a sparkline string from a circular buffer of throughput samples.
226fn 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
240/// Renders a level bar: filled blocks + empty blocks, `LEVEL_BAR_WIDTH` chars wide.
241fn 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
257/// Formats an uptime duration as HH:MM:SS.
258fn 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
265/// Builds the format counts line from metrics.
266pub 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
301/// Computes level bar rendering from metrics.
302///
303/// Returns `(info_bar, info_pct, error_bar, error_pct)`.
304pub 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
324/// Performs one tick of the TUI dashboard, returning the ANSI-formatted frame.
325///
326/// This function is extracted from the render loop for testability.
327pub 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    // Level bars
347    let (info_bar, info_pct, error_bar, error_pct) =
348        compute_level_bars(metrics);
349
350    // Format counts line
351    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
378/// Record this tick's throughput: events since the last tick, scaled
379/// from a ~16 ms tick to a second, into the metrics, the peak, and the
380/// sparkline ring. Returns the throughput and the peak.
381fn 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
399/// Time since the metrics started, as `HH:MM:SS`.
400fn 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
411/// Spawns the background TUI renderer thread.
412///
413/// # Panics
414///
415/// This function panics if the TUI background thread fails to spawn.
416pub fn spawn_tui_thread(
417    metrics: Arc<TuiMetrics>,
418    shutdown_flag: Arc<AtomicBool>,
419) {
420    // Record engine start time
421    #[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            // Clean up terminal state on exit
457            let _ = write!(std::io::stdout(), "\x1b[r\x1b[J");
458        })
459        .expect("Failed to spawn TUI thread");
460}
461
462/// Formats an integer with comma separators.
463fn 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;