1use crate::livemetrics::{RowStats, Sampler};
18use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
19use metrics_exporter_prometheus::PrometheusHandle;
20use std::collections::HashMap;
21use std::io::IsTerminal;
22use std::time::{Duration, Instant};
23
24pub fn is_progress_session(quiet: bool, tui: bool) -> bool {
30 !quiet && !tui && std::io::stdout().is_terminal() && std::io::stderr().is_terminal()
31}
32
33const RENDER_HZ: u8 = 10;
35const TICK: Duration = Duration::from_millis(100);
37
38pub async fn drive<T>(run: impl Future<Output = T>, pipeline: &str, handle: PrometheusHandle) -> T {
42 let multi = MultiProgress::with_draw_target(ProgressDrawTarget::stderr_with_hz(RENDER_HZ));
43 drive_with(run, pipeline, handle, multi, TICK).await
44}
45
46async fn drive_with<T>(
49 run: impl Future<Output = T>,
50 pipeline: &str,
51 handle: PrometheusHandle,
52 multi: MultiProgress,
53 tick: Duration,
54) -> T {
55 let mut bars: HashMap<String, ProgressBar> = HashMap::new();
56 let mut sampler = Sampler::new(pipeline);
57 let started = Instant::now();
58 let mut interval = tokio::time::interval(tick);
59 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
60
61 tokio::pin!(run);
62 let result = loop {
63 tokio::select! {
64 biased;
65 result = &mut run => break result,
66 _ = interval.tick() => {
67 handle.run_upkeep();
68 let elapsed = started.elapsed();
69 let model = sampler.observe(&handle.render(), elapsed);
70 for (row_id, row) in &model.rows {
71 let bar = bars.entry(row_id.clone()).or_insert_with(|| {
72 let pb = multi.add(ProgressBar::new_spinner());
73 pb.set_style(spinner_style());
74 pb.enable_steady_tick(Duration::from_millis(120));
75 pb
76 });
77 bar.set_message(format_row_line(row_id, row, elapsed));
78 }
79 }
80 }
81 };
82
83 handle.run_upkeep();
86 let elapsed = started.elapsed();
87 let model = sampler.observe(&handle.render(), elapsed);
88 for (row_id, bar) in &bars {
89 if let Some(row) = model.rows.get(row_id) {
90 bar.set_message(format_row_line(row_id, row, elapsed));
91 }
92 bar.finish();
93 }
94 result
95}
96
97fn spinner_style() -> ProgressStyle {
98 ProgressStyle::with_template("{spinner:.cyan} {msg}")
99 .unwrap_or_else(|_| ProgressStyle::default_spinner())
100 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏✓")
101}
102
103pub fn format_row_line(row_id: &str, row: &RowStats, elapsed: Duration) -> String {
107 let label = if row_id.is_empty() { "run" } else { row_id };
108 let src = if row.source.is_empty() {
109 "?"
110 } else {
111 &row.source
112 };
113 let sink = if row.sink.is_empty() { "?" } else { &row.sink };
114 let mut line = format!(
115 "{label:<16} {src}→{sink} {} in / {} out {}/s page {} {}",
116 format_count(row.records_in),
117 format_count(row.records_out),
118 format_rate(row.rate),
119 row.pages,
120 format_elapsed(elapsed),
121 );
122 if row.dlq_records > 0 {
123 line.push_str(&format!(" {} dlq", format_count(row.dlq_records)));
124 }
125 match row.finished {
126 Some(true) => line.push_str(" done"),
127 Some(false) => line.push_str(" FAILED"),
128 None => {}
129 }
130 line
131}
132
133fn format_count(n: u64) -> String {
135 if n < 1_000 {
136 n.to_string()
137 } else if n < 1_000_000 {
138 format!("{:.1}k", n as f64 / 1_000.0)
139 } else if n < 1_000_000_000 {
140 format!("{:.1}M", n as f64 / 1_000_000.0)
141 } else {
142 format!("{:.1}B", n as f64 / 1_000_000_000.0)
143 }
144}
145
146fn format_rate(r: f64) -> String {
147 if r >= 100.0 {
148 format!("{r:.0}")
149 } else if r >= 10.0 {
150 format!("{r:.1}")
151 } else {
152 format!("{r:.2}")
153 }
154}
155
156fn format_elapsed(d: Duration) -> String {
157 let secs = d.as_secs();
158 let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
159 if h > 0 {
160 format!("{h}h{m:02}m{s:02}s")
161 } else if m > 0 {
162 format!("{m}m{s:02}s")
163 } else {
164 format!("{s}s")
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn quiet_disables_progress() {
174 assert!(!is_progress_session(true, false));
175 }
176
177 #[test]
178 fn tui_takes_precedence_over_progress() {
179 assert!(!is_progress_session(false, true));
180 }
181
182 #[test]
183 fn non_tty_stdout_disables_progress() {
184 assert!(!is_progress_session(false, false) || std::io::stdout().is_terminal());
187 }
188
189 fn row(src: &str, sink: &str, rin: u64, rout: u64, pages: u64, rate: f64) -> RowStats {
190 RowStats {
191 source: src.into(),
192 sink: sink.into(),
193 records_in: rin,
194 records_out: rout,
195 pages,
196 rate,
197 ..Default::default()
198 }
199 }
200
201 #[test]
202 fn format_row_line_renders_all_fields() {
203 let r = row("rest", "jsonl", 1500, 1499, 3, 250.0);
204 let line = format_row_line("orders", &r, Duration::from_secs(65));
205 assert!(line.contains("orders"), "{line}");
206 assert!(line.contains("rest→jsonl"), "{line}");
207 assert!(line.contains("1.5k in"), "{line}");
208 assert!(line.contains("1.5k out"), "{line}");
209 assert!(line.contains("250/s"), "{line}");
210 assert!(line.contains("page 3"), "{line}");
211 assert!(line.contains("1m05s"), "{line}");
212 }
213
214 #[test]
215 fn empty_row_id_falls_back_to_run_label() {
216 let r = row("csv", "stdout", 10, 10, 1, 0.0);
217 let line = format_row_line("", &r, Duration::from_secs(1));
218 assert!(line.starts_with("run"), "{line}");
219 }
220
221 #[test]
222 fn unknown_connectors_render_placeholders() {
223 let r = row("", "", 0, 0, 0, 0.0);
224 let line = format_row_line("x", &r, Duration::ZERO);
225 assert!(line.contains("?→?"), "{line}");
226 }
227
228 #[test]
229 fn finished_and_dlq_annotations() {
230 let mut r = row("rest", "bq", 100, 90, 1, 0.0);
231 r.dlq_records = 10;
232 r.finished = Some(false);
233 let line = format_row_line("r", &r, Duration::from_secs(2));
234 assert!(line.contains("10 dlq"), "{line}");
235 assert!(line.contains("FAILED"), "{line}");
236 }
237
238 #[tokio::test(start_paused = true)]
239 async fn drive_loop_samples_and_finalizes() {
240 let handle = crate::livemetrics::install_metrics_recorder(None).expect("recorder");
243 metrics::counter!(
244 "faucet_source_records_total",
245 "pipeline" => "p", "row" => "r", "connector" => "rest"
246 )
247 .increment(5);
248 metrics::counter!(
249 "faucet_sink_records_total",
250 "pipeline" => "p", "row" => "r", "connector" => "jsonl"
251 )
252 .increment(5);
253
254 let multi = MultiProgress::with_draw_target(ProgressDrawTarget::hidden());
257 let out = drive_with(
258 async {
259 tokio::time::sleep(Duration::from_millis(350)).await;
260 "done"
261 },
262 "p",
263 handle,
264 multi,
265 Duration::from_millis(100),
266 )
267 .await;
268 assert_eq!(out, "done");
269 }
270
271 #[tokio::test(start_paused = true)]
272 async fn public_drive_wrapper_runs() {
273 let handle = crate::livemetrics::install_metrics_recorder(None).expect("recorder");
276 let out = drive(
277 async {
278 tokio::time::sleep(Duration::from_millis(120)).await;
279 99u8
280 },
281 "wrap",
282 handle,
283 )
284 .await;
285 assert_eq!(out, 99);
286 }
287
288 #[test]
289 fn count_and_elapsed_formatting() {
290 assert_eq!(format_count(999), "999");
291 assert_eq!(format_count(1_500), "1.5k");
292 assert_eq!(format_count(2_500_000), "2.5M");
293 assert_eq!(format_elapsed(Duration::from_secs(5)), "5s");
294 assert_eq!(format_elapsed(Duration::from_secs(125)), "2m05s");
295 assert_eq!(format_elapsed(Duration::from_secs(3_665)), "1h01m05s");
296 }
297}