1pub mod view;
20
21pub 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#[derive(Clone, Default)]
36pub struct LogBuffer {
37 inner: Arc<Mutex<VecDeque<String>>>,
38}
39
40const LOG_BUFFER_CAP: usize = 200;
41
42impl LogBuffer {
43 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 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
64pub 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
97static TUI_LOGS: OnceLock<LogBuffer> = OnceLock::new();
100
101pub fn is_tui_session(tui_flag: bool) -> bool {
104 tui_flag && std::io::stdout().is_terminal()
105}
106
107pub 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
122pub fn log_buffer() -> Option<LogBuffer> {
124 TUI_LOGS.get().cloned()
125}
126
127pub trait CancelEvents {
130 fn cancel_requested(&mut self) -> bool;
133}
134
135struct 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
159pub async fn drive<T>(
163 run: impl Future<Output = T>,
164 pipeline: &str,
165 handle: PrometheusHandle,
166 cancel: CancellationToken,
167) -> T {
168 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
185pub 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
227pub 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 assert!(!is_tui_session(true) || std::io::stdout().is_terminal());
290 assert!(!is_tui_session(false));
291 }
292
293 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 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 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 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 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 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 flush_logs_to_stderr(1);
397 flush_logs_to_stderr(1000);
398 }
399}