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 metrics;
20pub mod view;
21
22use crate::error::{CliError, CliResult};
23use faucet_core::CancellationToken;
24use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
25use std::collections::VecDeque;
26use std::io::IsTerminal;
27use std::sync::{Arc, Mutex, OnceLock};
28
29/// Ring-buffered log lines shared between the tracing subscriber (writer
30/// side, installed in `run_main`) and the TUI (render side).
31#[derive(Clone, Default)]
32pub struct LogBuffer {
33    inner: Arc<Mutex<VecDeque<String>>>,
34}
35
36const LOG_BUFFER_CAP: usize = 200;
37
38impl LogBuffer {
39    /// Append one already-formatted subscriber line (redacted at capture).
40    pub fn push_line(&self, line: &str) {
41        let line = crate::secrets::registry::redact(line).into_owned();
42        let mut q = self.inner.lock().unwrap_or_else(|p| p.into_inner());
43        if q.len() == LOG_BUFFER_CAP {
44            q.pop_front();
45        }
46        q.push_back(line);
47    }
48
49    /// Snapshot the buffered lines (oldest first).
50    pub fn snapshot(&self) -> Vec<String> {
51        self.inner
52            .lock()
53            .unwrap_or_else(|p| p.into_inner())
54            .iter()
55            .cloned()
56            .collect()
57    }
58}
59
60/// Line-splitting `io::Write` adapter for the tracing subscriber.
61pub struct LogBufferWriter {
62    buffer: LogBuffer,
63    partial: String,
64}
65
66impl std::io::Write for LogBufferWriter {
67    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
68        self.partial.push_str(&String::from_utf8_lossy(buf));
69        while let Some(at) = self.partial.find('\n') {
70            let line: String = self.partial.drain(..=at).collect();
71            let line = line.trim_end();
72            if !line.is_empty() {
73                self.buffer.push_line(line);
74            }
75        }
76        Ok(buf.len())
77    }
78    fn flush(&mut self) -> std::io::Result<()> {
79        Ok(())
80    }
81}
82
83impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogBuffer {
84    type Writer = LogBufferWriter;
85    fn make_writer(&'a self) -> Self::Writer {
86        LogBufferWriter {
87            buffer: self.clone(),
88            partial: String::new(),
89        }
90    }
91}
92
93/// The process-wide TUI log ring. Populated by `run_main` when it detects a
94/// TUI session before installing the subscriber; read by the render loop.
95static TUI_LOGS: OnceLock<LogBuffer> = OnceLock::new();
96
97/// `--tui` was passed *and* stdout is a real terminal — the full-screen UI
98/// applies. On a non-TTY the caller degrades to a plain run.
99pub fn is_tui_session(tui_flag: bool) -> bool {
100    tui_flag && std::io::stdout().is_terminal()
101}
102
103/// Install the ring-buffered tracing subscriber for a TUI session. Called by
104/// `run_main` *instead of* the stdout subscriber, before any log line is
105/// emitted. Returns the buffer for the render loop.
106pub fn install_tui_tracing(level: &str) -> LogBuffer {
107    use tracing_subscriber::EnvFilter;
108    let buffer = TUI_LOGS.get_or_init(LogBuffer::default).clone();
109    let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
110    let _ = tracing_subscriber::fmt()
111        .with_env_filter(filter)
112        .with_ansi(false)
113        .with_writer(buffer.clone())
114        .try_init();
115    buffer
116}
117
118/// The TUI log ring, if `run_main` installed one this process.
119pub fn log_buffer() -> Option<LogBuffer> {
120    TUI_LOGS.get().cloned()
121}
122
123/// Default histogram buckets — mirrors `faucet-core`'s installer so a
124/// TUI-owned recorder behaves identically for any `/metrics` scraper.
125const DEFAULT_BUCKETS: &[f64] = &[
126    0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
127];
128
129/// The handle of the recorder this module installed, if any — makes
130/// [`install_metrics_recorder`] idempotent (second call returns the same
131/// handle instead of racing on the process-global recorder slot).
132static RECORDER_HANDLE: OnceLock<PrometheusHandle> = OnceLock::new();
133
134/// Install the Prometheus recorder for a TUI session and return its render
135/// handle. When the config carries an `observability.prometheus` block the
136/// `/metrics` HTTP endpoint is preserved (recorder + listener, exactly what
137/// `install_observability` would have set up); otherwise a listener-less
138/// recorder is installed purely as the TUI's data source. Idempotent: a
139/// second call returns the first call's handle.
140pub fn install_metrics_recorder(
141    prom: Option<&faucet_core::PrometheusConfig>,
142) -> CliResult<PrometheusHandle> {
143    // Validate the listener address up front so a config error surfaces even
144    // when the recorder slot is already occupied.
145    let listen: Option<std::net::SocketAddr> = match prom {
146        Some(p) => Some(
147            p.listen
148                .parse()
149                .map_err(|e| CliError::Observability(format!("prometheus listen: {e}")))?,
150        ),
151        None => None,
152    };
153    if let Some(handle) = RECORDER_HANDLE.get() {
154        return Ok(handle.clone());
155    }
156    let handle = match (prom, listen) {
157        (Some(p), Some(listen)) => {
158            let (recorder, exporter) = PrometheusBuilder::new()
159                .with_http_listener(listen)
160                .set_buckets(p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS))
161                .map_err(|e| CliError::Observability(e.to_string()))?
162                .build()
163                .map_err(|e| CliError::Observability(e.to_string()))?;
164            let handle = recorder.handle();
165            if ::metrics::set_global_recorder(recorder).is_ok() {
166                tokio::spawn(exporter);
167                tracing::info!("Prometheus /metrics listening on {}", p.listen);
168            } else {
169                warn_recorder_occupied();
170            }
171            handle
172        }
173        _ => {
174            let recorder = PrometheusBuilder::new()
175                .set_buckets(DEFAULT_BUCKETS)
176                .map_err(|e| CliError::Observability(e.to_string()))?
177                .build_recorder();
178            let handle = recorder.handle();
179            if ::metrics::set_global_recorder(recorder).is_err() {
180                warn_recorder_occupied();
181            }
182            handle
183        }
184    };
185    Ok(RECORDER_HANDLE.get_or_init(|| handle).clone())
186}
187
188fn warn_recorder_occupied() {
189    tracing::warn!(
190        "metrics recorder already installed; the TUI may show no data (was a recorder installed before `faucet run --tui`?)"
191    );
192}
193
194/// Install the TUI session's observability: the TUI owns the metrics
195/// recorder (keeping the `/metrics` endpoint when one is configured) so it
196/// can render the recorder's output; the rest of the observability config
197/// (OTLP traces — the tracing level was already routed into the TUI log ring
198/// by `run_main`) installs as usual with the prometheus block taken out.
199pub fn setup_observability(cfg: &crate::config::PipelineConfig) -> CliResult<PrometheusHandle> {
200    let mut obs_cfg = crate::obs::build_observability_config(cfg);
201    let prom = obs_cfg.prometheus.take();
202    let handle = install_metrics_recorder(prom.as_ref())?;
203    faucet_core::install_observability(&obs_cfg)?;
204    Ok(handle)
205}
206
207/// Source of user cancel requests — abstracted from crossterm so
208/// [`drive_loop`] is testable against a scripted sequence.
209pub trait CancelEvents {
210    /// Drain any pending input; `true` when the user asked to cancel
211    /// (`q` / `Ctrl-C`).
212    fn cancel_requested(&mut self) -> bool;
213}
214
215/// The real, non-blocking crossterm key poll.
216struct CrosstermEvents;
217
218impl CancelEvents for CrosstermEvents {
219    fn cancel_requested(&mut self) -> bool {
220        use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
221        let mut requested = false;
222        while ratatui::crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) {
223            match ratatui::crossterm::event::read() {
224                Ok(Event::Key(key)) => {
225                    let ctrl_c = key.code == KeyCode::Char('c')
226                        && key.modifiers.contains(KeyModifiers::CONTROL);
227                    if key.code == KeyCode::Char('q') || ctrl_c {
228                        requested = true;
229                    }
230                }
231                Ok(_) => {}
232                Err(_) => break,
233            }
234        }
235        requested
236    }
237}
238
239/// Drive the run future under the full-screen TUI. Returns the run's result
240/// after the terminal is restored. `cancel` is the token wired into
241/// `ExecuteOptions.cancel`; `q` / `Ctrl-C` trigger it.
242pub async fn drive<T>(
243    run: impl Future<Output = T>,
244    pipeline: &str,
245    handle: PrometheusHandle,
246    cancel: CancellationToken,
247) -> T {
248    // `ratatui::init` enters the alternate screen + raw mode and installs a
249    // panic hook that restores the terminal before the default hook runs.
250    let mut terminal = ratatui::init();
251    let result = drive_loop(
252        &mut terminal,
253        CrosstermEvents,
254        run,
255        pipeline,
256        handle,
257        cancel,
258        std::time::Duration::from_millis(250),
259    )
260    .await;
261    ratatui::restore();
262    result
263}
264
265/// The render/cancel loop behind [`drive`], generic over the terminal
266/// backend and the event source so it runs headless under test.
267pub async fn drive_loop<B, E, T>(
268    terminal: &mut ratatui::Terminal<B>,
269    mut events: E,
270    run: impl Future<Output = T>,
271    pipeline: &str,
272    handle: PrometheusHandle,
273    cancel: CancellationToken,
274    tick: std::time::Duration,
275) -> T
276where
277    B: ratatui::backend::Backend,
278    E: CancelEvents,
279{
280    let started = std::time::Instant::now();
281    let mut sampler = metrics::Sampler::new(pipeline);
282    let logs = log_buffer().unwrap_or_default();
283    let mut interval = tokio::time::interval(tick);
284    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
285
286    tokio::pin!(run);
287    loop {
288        tokio::select! {
289            biased;
290            result = &mut run => break result,
291            _ = interval.tick() => {
292                if events.cancel_requested() {
293                    cancel.cancel();
294                }
295                handle.run_upkeep();
296                let model = sampler.observe(&handle.render(), started.elapsed());
297                let log_lines = logs.snapshot();
298                let cancelling = cancel.is_cancelled();
299                let _ = terminal.draw(|frame| {
300                    view::draw(frame, pipeline, &model, started.elapsed(), &log_lines, cancelling);
301                });
302            }
303        }
304    }
305}
306
307/// Flush the tail of the buffered log ring to stderr — called after terminal
308/// restore when the run failed, so the operator keeps the context that was
309/// on screen.
310pub fn flush_logs_to_stderr(max_lines: usize) {
311    if let Some(buffer) = log_buffer() {
312        let lines = buffer.snapshot();
313        let start = lines.len().saturating_sub(max_lines);
314        for line in &lines[start..] {
315            eprintln!("{line}");
316        }
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use std::io::Write;
324
325    #[test]
326    fn log_buffer_caps_and_orders_lines() {
327        let buffer = LogBuffer::default();
328        for i in 0..(LOG_BUFFER_CAP + 10) {
329            buffer.push_line(&format!("line {i}"));
330        }
331        let snap = buffer.snapshot();
332        assert_eq!(snap.len(), LOG_BUFFER_CAP);
333        assert_eq!(snap.first().unwrap(), "line 10");
334        assert_eq!(
335            snap.last().unwrap(),
336            &format!("line {}", LOG_BUFFER_CAP + 9)
337        );
338    }
339
340    #[test]
341    fn writer_splits_lines_and_keeps_partials() {
342        let buffer = LogBuffer::default();
343        let mut writer = LogBufferWriter {
344            buffer: buffer.clone(),
345            partial: String::new(),
346        };
347        writer.write_all(b"first line\nsecond ").unwrap();
348        writer.write_all(b"half\ntrailing").unwrap();
349        let snap = buffer.snapshot();
350        assert_eq!(
351            snap,
352            vec!["first line".to_string(), "second half".to_string()]
353        );
354    }
355
356    #[test]
357    fn log_lines_are_redacted_at_capture() {
358        crate::secrets::registry::register("tui-secret-value");
359        let buffer = LogBuffer::default();
360        buffer.push_line("token is tui-secret-value here");
361        let snap = buffer.snapshot();
362        assert!(!snap[0].contains("tui-secret-value"), "got: {}", snap[0]);
363    }
364
365    #[test]
366    fn non_tty_is_not_a_tui_session() {
367        // Test harnesses never run on a TTY stdout, so the flag alone must
368        // not start a session — this is the CI/pipe fallback contract.
369        assert!(!is_tui_session(true) || std::io::stdout().is_terminal());
370        assert!(!is_tui_session(false));
371    }
372
373    /// Scripted event source: yields `true` once at the configured tick.
374    struct ScriptedEvents {
375        cancel_on_call: usize,
376        calls: usize,
377    }
378
379    impl CancelEvents for ScriptedEvents {
380        fn cancel_requested(&mut self) -> bool {
381            self.calls += 1;
382            self.calls == self.cancel_on_call
383        }
384    }
385
386    /// Never cancels.
387    struct NoEvents;
388    impl CancelEvents for NoEvents {
389        fn cancel_requested(&mut self) -> bool {
390            false
391        }
392    }
393
394    fn test_terminal() -> ratatui::Terminal<ratatui::backend::TestBackend> {
395        ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24)).expect("terminal")
396    }
397
398    fn recorder_handle() -> PrometheusHandle {
399        // The process-global recorder slot may be taken by any other test in
400        // this binary; install_metrics_recorder tolerates that and hands back
401        // a usable handle either way.
402        install_metrics_recorder(None).expect("recorder")
403    }
404
405    #[tokio::test(start_paused = true)]
406    async fn drive_loop_ticks_render_and_exit_on_run_completion() {
407        let mut terminal = test_terminal();
408        let cancel = CancellationToken::new();
409        let result = drive_loop(
410            &mut terminal,
411            NoEvents,
412            async {
413                tokio::time::sleep(std::time::Duration::from_millis(320)).await;
414                42
415            },
416            "loop-pipeline",
417            recorder_handle(),
418            cancel.clone(),
419            std::time::Duration::from_millis(100),
420        )
421        .await;
422        assert_eq!(result, 42);
423        assert!(!cancel.is_cancelled());
424        // At least one tick rendered the header into the test buffer.
425        let text: String = terminal
426            .backend()
427            .buffer()
428            .content()
429            .iter()
430            .map(|cell| cell.symbol())
431            .collect();
432        assert!(text.contains("faucet run · loop-pipeline"), "{text}");
433    }
434
435    #[tokio::test(start_paused = true)]
436    async fn drive_loop_fires_the_cancel_token_on_user_request() {
437        let mut terminal = test_terminal();
438        let cancel = CancellationToken::new();
439        let run_cancel = cancel.clone();
440        let result = drive_loop(
441            &mut terminal,
442            ScriptedEvents {
443                cancel_on_call: 2,
444                calls: 0,
445            },
446            async move {
447                // A cooperative pipeline: winds down when cancelled.
448                run_cancel.cancelled().await;
449                "cancelled"
450            },
451            "loop-pipeline",
452            recorder_handle(),
453            cancel.clone(),
454            std::time::Duration::from_millis(50),
455        )
456        .await;
457        assert_eq!(result, "cancelled");
458        assert!(cancel.is_cancelled());
459        // The frame after the cancel shows the banner.
460        let text: String = terminal
461            .backend()
462            .buffer()
463            .content()
464            .iter()
465            .map(|cell| cell.symbol())
466            .collect();
467        assert!(text.contains("cancelling…"), "{text}");
468    }
469
470    #[test]
471    fn install_metrics_recorder_is_idempotent() {
472        let first = install_metrics_recorder(None).expect("first install");
473        let second = install_metrics_recorder(None).expect("second install");
474        // Both render (same underlying registry once cached).
475        let _ = first.render();
476        let _ = second.render();
477    }
478
479    #[test]
480    fn install_metrics_recorder_rejects_a_bad_listen_address() {
481        let prom = faucet_core::PrometheusConfig {
482            listen: "not-an-address".into(),
483            buckets: None,
484        };
485        let err = install_metrics_recorder(Some(&prom)).expect_err("bad listen");
486        assert!(matches!(err, CliError::Observability(_)), "{err:?}");
487    }
488
489    #[tokio::test]
490    async fn install_metrics_recorder_accepts_a_listener_config() {
491        // Ephemeral port; whichever install wins the process-global slot,
492        // the call must succeed and hand back a handle.
493        let prom = faucet_core::PrometheusConfig {
494            listen: "127.0.0.1:0".into(),
495            buckets: None,
496        };
497        let handle = install_metrics_recorder(Some(&prom)).expect("listener install");
498        let _ = handle.render();
499    }
500
501    #[test]
502    fn flush_logs_to_stderr_replays_the_tail() {
503        let buffer = install_tui_tracing("info");
504        buffer.push_line("tail line A");
505        buffer.push_line("tail line B");
506        // Covers the ring lookup + tail slicing; output goes to stderr.
507        flush_logs_to_stderr(1);
508        flush_logs_to_stderr(1000);
509    }
510}