Skip to main content

faucet_cli/tui/
mod.rs

1//! Live terminal UI for `faucet run --tui` (#203, `cli-tui` feature).
2//!
3//! The pipeline is untouched: it emits the same `metrics` series it always
4//! does, and the TUI samples the in-process Prometheus recorder's rendered
5//! text a few times per second (read-only — zero hot-path impact) and draws
6//! a full-screen [ratatui] view: per-invocation throughput, errors, DLQ
7//! counts, bookmark age, and a live log pane. `q` (or `Ctrl-C`, which raw
8//! mode delivers as a key event) cancels cooperatively via the executor's
9//! [`CancellationToken`] — in-flight invocations stop at their next page
10//! boundary and flush their sinks.
11//!
12//! Log handling: the normal stdout subscriber would corrupt the alternate
13//! screen, so when a TUI session is detected at startup (`--tui` on a real
14//! TTY), `run_main` routes the fmt subscriber into an in-memory ring
15//! ([`log_buffer`]) that the TUI renders as its log pane. Lines are redacted
16//! at capture with the same registry the stdout writer uses. On a non-TTY
17//! (CI, pipes) the flag degrades to a plain run with a one-line notice.
18
19pub mod view;
20
21// The Prometheus recorder installer and the pure text sampler now live in the
22// shared `livemetrics` module (reused by the `cli-progress` line); re-export
23// them here so the TUI's `crate::tui::{metrics, setup_observability, …}` call
24// sites are unchanged.
25pub use crate::livemetrics::{self as metrics, install_metrics_recorder, setup_observability};
26
27use faucet_core::CancellationToken;
28use metrics_exporter_prometheus::PrometheusHandle;
29use std::collections::VecDeque;
30use std::io::IsTerminal;
31use std::sync::{Arc, Mutex, OnceLock};
32
33/// Ring-buffered log lines shared between the tracing subscriber (writer
34/// side, installed in `run_main`) and the TUI (render side).
35#[derive(Clone, Default)]
36pub struct LogBuffer {
37    inner: Arc<Mutex<VecDeque<String>>>,
38}
39
40const LOG_BUFFER_CAP: usize = 200;
41
42impl LogBuffer {
43    /// Append one already-formatted subscriber line (redacted at capture).
44    pub fn push_line(&self, line: &str) {
45        let line = crate::secrets::registry::redact(line).into_owned();
46        let mut q = self.inner.lock().unwrap_or_else(|p| p.into_inner());
47        if q.len() == LOG_BUFFER_CAP {
48            q.pop_front();
49        }
50        q.push_back(line);
51    }
52
53    /// Snapshot the buffered lines (oldest first).
54    pub fn snapshot(&self) -> Vec<String> {
55        self.inner
56            .lock()
57            .unwrap_or_else(|p| p.into_inner())
58            .iter()
59            .cloned()
60            .collect()
61    }
62}
63
64/// Line-splitting `io::Write` adapter for the tracing subscriber.
65pub struct LogBufferWriter {
66    buffer: LogBuffer,
67    partial: String,
68}
69
70impl std::io::Write for LogBufferWriter {
71    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
72        self.partial.push_str(&String::from_utf8_lossy(buf));
73        while let Some(at) = self.partial.find('\n') {
74            let line: String = self.partial.drain(..=at).collect();
75            let line = line.trim_end();
76            if !line.is_empty() {
77                self.buffer.push_line(line);
78            }
79        }
80        Ok(buf.len())
81    }
82    fn flush(&mut self) -> std::io::Result<()> {
83        Ok(())
84    }
85}
86
87impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogBuffer {
88    type Writer = LogBufferWriter;
89    fn make_writer(&'a self) -> Self::Writer {
90        LogBufferWriter {
91            buffer: self.clone(),
92            partial: String::new(),
93        }
94    }
95}
96
97/// The process-wide TUI log ring. Populated by `run_main` when it detects a
98/// TUI session before installing the subscriber; read by the render loop.
99static TUI_LOGS: OnceLock<LogBuffer> = OnceLock::new();
100
101/// `--tui` was passed *and* stdout is a real terminal — the full-screen UI
102/// applies. On a non-TTY the caller degrades to a plain run.
103pub fn is_tui_session(tui_flag: bool) -> bool {
104    tui_flag && std::io::stdout().is_terminal()
105}
106
107/// Install the ring-buffered tracing subscriber for a TUI session. Called by
108/// `run_main` *instead of* the stdout subscriber, before any log line is
109/// emitted. Returns the buffer for the render loop.
110pub fn install_tui_tracing(level: &str) -> LogBuffer {
111    use tracing_subscriber::EnvFilter;
112    let buffer = TUI_LOGS.get_or_init(LogBuffer::default).clone();
113    let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
114    let _ = tracing_subscriber::fmt()
115        .with_env_filter(filter)
116        .with_ansi(false)
117        .with_writer(buffer.clone())
118        .try_init();
119    buffer
120}
121
122/// The TUI log ring, if `run_main` installed one this process.
123pub fn log_buffer() -> Option<LogBuffer> {
124    TUI_LOGS.get().cloned()
125}
126
127/// Source of user cancel requests — abstracted from crossterm so
128/// [`drive_loop`] is testable against a scripted sequence.
129pub trait CancelEvents {
130    /// Drain any pending input; `true` when the user asked to cancel
131    /// (`q` / `Ctrl-C`).
132    fn cancel_requested(&mut self) -> bool;
133}
134
135/// The real, non-blocking crossterm key poll.
136struct CrosstermEvents;
137
138impl CancelEvents for CrosstermEvents {
139    fn cancel_requested(&mut self) -> bool {
140        use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
141        let mut requested = false;
142        while ratatui::crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) {
143            match ratatui::crossterm::event::read() {
144                Ok(Event::Key(key)) => {
145                    let ctrl_c = key.code == KeyCode::Char('c')
146                        && key.modifiers.contains(KeyModifiers::CONTROL);
147                    if key.code == KeyCode::Char('q') || ctrl_c {
148                        requested = true;
149                    }
150                }
151                Ok(_) => {}
152                Err(_) => break,
153            }
154        }
155        requested
156    }
157}
158
159/// Drive the run future under the full-screen TUI. Returns the run's result
160/// after the terminal is restored. `cancel` is the token wired into
161/// `ExecuteOptions.cancel`; `q` / `Ctrl-C` trigger it.
162pub async fn drive<T>(
163    run: impl Future<Output = T>,
164    pipeline: &str,
165    handle: PrometheusHandle,
166    cancel: CancellationToken,
167) -> T {
168    // `ratatui::init` enters the alternate screen + raw mode and installs a
169    // panic hook that restores the terminal before the default hook runs.
170    let mut terminal = ratatui::init();
171    let result = drive_loop(
172        &mut terminal,
173        CrosstermEvents,
174        run,
175        pipeline,
176        handle,
177        cancel,
178        std::time::Duration::from_millis(250),
179    )
180    .await;
181    ratatui::restore();
182    result
183}
184
185/// The render/cancel loop behind [`drive`], generic over the terminal
186/// backend and the event source so it runs headless under test.
187pub async fn drive_loop<B, E, T>(
188    terminal: &mut ratatui::Terminal<B>,
189    mut events: E,
190    run: impl Future<Output = T>,
191    pipeline: &str,
192    handle: PrometheusHandle,
193    cancel: CancellationToken,
194    tick: std::time::Duration,
195) -> T
196where
197    B: ratatui::backend::Backend,
198    E: CancelEvents,
199{
200    let started = std::time::Instant::now();
201    let mut sampler = metrics::Sampler::new(pipeline);
202    let logs = log_buffer().unwrap_or_default();
203    let mut interval = tokio::time::interval(tick);
204    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
205
206    tokio::pin!(run);
207    loop {
208        tokio::select! {
209            biased;
210            result = &mut run => break result,
211            _ = interval.tick() => {
212                if events.cancel_requested() {
213                    cancel.cancel();
214                }
215                handle.run_upkeep();
216                let model = sampler.observe(&handle.render(), started.elapsed());
217                let log_lines = logs.snapshot();
218                let cancelling = cancel.is_cancelled();
219                let _ = terminal.draw(|frame| {
220                    view::draw(frame, pipeline, &model, started.elapsed(), &log_lines, cancelling);
221                });
222            }
223        }
224    }
225}
226
227/// Flush the tail of the buffered log ring to stderr — called after terminal
228/// restore when the run failed, so the operator keeps the context that was
229/// on screen.
230pub fn flush_logs_to_stderr(max_lines: usize) {
231    if let Some(buffer) = log_buffer() {
232        let lines = buffer.snapshot();
233        let start = lines.len().saturating_sub(max_lines);
234        for line in &lines[start..] {
235            eprintln!("{line}");
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use std::io::Write;
244
245    #[test]
246    fn log_buffer_caps_and_orders_lines() {
247        let buffer = LogBuffer::default();
248        for i in 0..(LOG_BUFFER_CAP + 10) {
249            buffer.push_line(&format!("line {i}"));
250        }
251        let snap = buffer.snapshot();
252        assert_eq!(snap.len(), LOG_BUFFER_CAP);
253        assert_eq!(snap.first().unwrap(), "line 10");
254        assert_eq!(
255            snap.last().unwrap(),
256            &format!("line {}", LOG_BUFFER_CAP + 9)
257        );
258    }
259
260    #[test]
261    fn writer_splits_lines_and_keeps_partials() {
262        let buffer = LogBuffer::default();
263        let mut writer = LogBufferWriter {
264            buffer: buffer.clone(),
265            partial: String::new(),
266        };
267        writer.write_all(b"first line\nsecond ").unwrap();
268        writer.write_all(b"half\ntrailing").unwrap();
269        let snap = buffer.snapshot();
270        assert_eq!(
271            snap,
272            vec!["first line".to_string(), "second half".to_string()]
273        );
274    }
275
276    #[test]
277    fn log_lines_are_redacted_at_capture() {
278        crate::secrets::registry::register("tui-secret-value");
279        let buffer = LogBuffer::default();
280        buffer.push_line("token is tui-secret-value here");
281        let snap = buffer.snapshot();
282        assert!(!snap[0].contains("tui-secret-value"), "got: {}", snap[0]);
283    }
284
285    #[test]
286    fn non_tty_is_not_a_tui_session() {
287        // Test harnesses never run on a TTY stdout, so the flag alone must
288        // not start a session — this is the CI/pipe fallback contract.
289        assert!(!is_tui_session(true) || std::io::stdout().is_terminal());
290        assert!(!is_tui_session(false));
291    }
292
293    /// Scripted event source: yields `true` once at the configured tick.
294    struct ScriptedEvents {
295        cancel_on_call: usize,
296        calls: usize,
297    }
298
299    impl CancelEvents for ScriptedEvents {
300        fn cancel_requested(&mut self) -> bool {
301            self.calls += 1;
302            self.calls == self.cancel_on_call
303        }
304    }
305
306    /// Never cancels.
307    struct NoEvents;
308    impl CancelEvents for NoEvents {
309        fn cancel_requested(&mut self) -> bool {
310            false
311        }
312    }
313
314    fn test_terminal() -> ratatui::Terminal<ratatui::backend::TestBackend> {
315        ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24)).expect("terminal")
316    }
317
318    fn recorder_handle() -> PrometheusHandle {
319        // The process-global recorder slot may be taken by any other test in
320        // this binary; install_metrics_recorder tolerates that and hands back
321        // a usable handle either way.
322        install_metrics_recorder(None).expect("recorder")
323    }
324
325    #[tokio::test(start_paused = true)]
326    async fn drive_loop_ticks_render_and_exit_on_run_completion() {
327        let mut terminal = test_terminal();
328        let cancel = CancellationToken::new();
329        let result = drive_loop(
330            &mut terminal,
331            NoEvents,
332            async {
333                tokio::time::sleep(std::time::Duration::from_millis(320)).await;
334                42
335            },
336            "loop-pipeline",
337            recorder_handle(),
338            cancel.clone(),
339            std::time::Duration::from_millis(100),
340        )
341        .await;
342        assert_eq!(result, 42);
343        assert!(!cancel.is_cancelled());
344        // At least one tick rendered the header into the test buffer.
345        let text: String = terminal
346            .backend()
347            .buffer()
348            .content()
349            .iter()
350            .map(|cell| cell.symbol())
351            .collect();
352        assert!(text.contains("faucet run · loop-pipeline"), "{text}");
353    }
354
355    #[tokio::test(start_paused = true)]
356    async fn drive_loop_fires_the_cancel_token_on_user_request() {
357        let mut terminal = test_terminal();
358        let cancel = CancellationToken::new();
359        let run_cancel = cancel.clone();
360        let result = drive_loop(
361            &mut terminal,
362            ScriptedEvents {
363                cancel_on_call: 2,
364                calls: 0,
365            },
366            async move {
367                // A cooperative pipeline: winds down when cancelled.
368                run_cancel.cancelled().await;
369                "cancelled"
370            },
371            "loop-pipeline",
372            recorder_handle(),
373            cancel.clone(),
374            std::time::Duration::from_millis(50),
375        )
376        .await;
377        assert_eq!(result, "cancelled");
378        assert!(cancel.is_cancelled());
379        // The frame after the cancel shows the banner.
380        let text: String = terminal
381            .backend()
382            .buffer()
383            .content()
384            .iter()
385            .map(|cell| cell.symbol())
386            .collect();
387        assert!(text.contains("cancelling…"), "{text}");
388    }
389
390    #[test]
391    fn flush_logs_to_stderr_replays_the_tail() {
392        let buffer = install_tui_tracing("info");
393        buffer.push_line("tail line A");
394        buffer.push_line("tail line B");
395        // Covers the ring lookup + tail slicing; output goes to stderr.
396        flush_logs_to_stderr(1);
397        flush_logs_to_stderr(1000);
398    }
399}