1use crate::cli::DatabricksCli;
2use crate::fetchers::jobs::run_status;
3use crate::shape::{fmt_duration_ms, relative_time, DetailData, Status};
4
5pub async fn list(
7 cli: &DatabricksCli,
8 job_id: &str,
9) -> Result<Vec<(String, Status, String)>, String> {
10 let args = ["jobs", "list-runs", "--job-id", job_id, "--limit", "20"];
11 let json = cli.run(&args).await.map_err(|e| format!("{e:#}"))?;
12 Ok(json
13 .as_array()
14 .map(|runs| {
15 runs.iter()
16 .filter_map(|r| {
17 let id = r["run_id"].as_u64()?;
18 let age = r["start_time"].as_u64().map(relative_time)?;
19 Some((id.to_string(), run_status(r), age))
20 })
21 .collect()
22 })
23 .unwrap_or_default())
24}
25
26pub async fn state(cli: &DatabricksCli, run_id: &str) -> Result<(Status, bool), String> {
28 let json = cli
29 .run(&["jobs", "get-run", run_id])
30 .await
31 .map_err(|e| format!("{e:#}"))?;
32 let status = run_status(&json);
33 let live = matches!(status, Status::Running | Status::Pending);
34 Ok((status, live))
35}
36
37pub struct GridRun {
39 pub run_id: String,
40 pub status: Status,
41 pub age: String,
42}
43
44pub struct GridTask {
48 pub key: String,
49 pub cells: Vec<Option<Status>>,
50 pub durations: Vec<Option<u64>>,
51}
52
53impl GridTask {
54 pub fn success_durations(&self) -> Vec<u64> {
57 self.cells
58 .iter()
59 .zip(&self.durations)
60 .filter(|(c, _)| matches!(c, Some(Status::Success)))
61 .filter_map(|(_, d)| *d)
62 .collect()
63 }
64
65 pub fn slowdown(&self) -> Option<(u64, u64)> {
69 let ds = self.success_durations();
70 let (latest, prior) = ds.split_last()?;
71 if prior.len() < 3 {
72 return None;
73 }
74 let mut sorted = prior.to_vec();
75 sorted.sort_unstable();
76 let median = sorted[sorted.len() / 2];
77 (median > 0 && *latest * 2 >= median * 3).then_some((*latest, median))
78 }
79}
80
81pub struct GridData {
83 pub runs: Vec<GridRun>,
85 pub tasks: Vec<GridTask>,
86}
87
88pub async fn grid(cli: &DatabricksCli, job_id: &str) -> Result<GridData, String> {
91 let args = [
92 "jobs",
93 "list-runs",
94 "--job-id",
95 job_id,
96 "--expand-tasks",
97 "--limit",
98 "20",
99 ];
100 let json = cli.run(&args).await.map_err(|e| format!("{e:#}"))?;
101 Ok(grid_from(&json))
102}
103
104fn grid_from(json: &serde_json::Value) -> GridData {
105 let mut runs_json = json
106 .as_array()
107 .cloned()
108 .or_else(|| json["runs"].as_array().cloned())
109 .unwrap_or_default();
110 runs_json.reverse();
112 let runs: Vec<GridRun> = runs_json
113 .iter()
114 .map(|r| GridRun {
115 run_id: r["run_id"]
116 .as_u64()
117 .map(|i| i.to_string())
118 .unwrap_or_default(),
119 status: run_status(r),
120 age: r["start_time"]
121 .as_u64()
122 .map(relative_time)
123 .unwrap_or_default(),
124 })
125 .collect();
126 let mut keys: Vec<String> = Vec::new();
129 for r in runs_json.iter().rev() {
130 for t in r["tasks"].as_array().into_iter().flatten() {
131 if let Some(k) = t["task_key"].as_str() {
132 if !keys.iter().any(|e| e == k) {
133 keys.push(k.to_string());
134 }
135 }
136 }
137 }
138 let tasks = keys
139 .into_iter()
140 .map(|key| {
141 let mut cells = Vec::with_capacity(runs_json.len());
142 let mut durations = Vec::with_capacity(runs_json.len());
143 for r in &runs_json {
144 let t = r["tasks"]
145 .as_array()
146 .and_then(|ts| ts.iter().find(|t| t["task_key"].as_str() == Some(&key)));
147 cells.push(t.map(run_status));
148 durations.push(t.and_then(|t| {
149 t["execution_duration"]
150 .as_u64()
151 .or_else(|| t["run_duration"].as_u64())
152 .filter(|d| *d > 0)
153 }));
154 }
155 GridTask {
156 key,
157 cells,
158 durations,
159 }
160 })
161 .collect();
162 GridData { runs, tasks }
163}
164
165pub struct TimelineTask {
167 pub key: String,
168 pub start: u64,
170 pub end: Option<u64>,
172 pub status: Status,
173}
174
175pub fn timeline(raw: &str) -> Vec<TimelineTask> {
178 let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) else {
179 return Vec::new();
180 };
181 let mut tasks: Vec<TimelineTask> = json["tasks"]
182 .as_array()
183 .map(|ts| {
184 ts.iter()
185 .map(|t| {
186 let start = t["start_time"].as_u64().unwrap_or(0);
187 let end = t["end_time"].as_u64().filter(|e| *e > 0 && start > 0);
188 TimelineTask {
189 key: t["task_key"].as_str().unwrap_or("?").to_string(),
190 start,
191 end,
192 status: run_status(t),
193 }
194 })
195 .collect()
196 })
197 .unwrap_or_default();
198 tasks.sort_by_key(|t| if t.start == 0 { u64::MAX } else { t.start });
199 tasks
200}
201
202pub struct DagRow {
204 pub prefix: String,
206 pub key: String,
207 pub status: Status,
208 pub duration: Option<u64>,
210 pub also_after: Vec<String>,
212}
213
214pub fn dag(raw: &str) -> Vec<DagRow> {
219 let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) else {
220 return Vec::new();
221 };
222 let tasks = json["tasks"].as_array().cloned().unwrap_or_default();
223 let info: Vec<(String, Vec<String>, Status, Option<u64>)> = tasks
224 .iter()
225 .map(|t| {
226 let deps: Vec<String> = t["depends_on"]
227 .as_array()
228 .map(|ds| {
229 ds.iter()
230 .filter_map(|d| d["task_key"].as_str().map(str::to_string))
231 .collect()
232 })
233 .unwrap_or_default();
234 let dur = t["execution_duration"]
235 .as_u64()
236 .or_else(|| t["run_duration"].as_u64())
237 .filter(|d| *d > 0);
238 (
239 t["task_key"].as_str().unwrap_or("?").to_string(),
240 deps,
241 run_status(t),
242 dur,
243 )
244 })
245 .collect();
246 let keys: Vec<&str> = info.iter().map(|(k, _, _, _)| k.as_str()).collect();
247
248 let mut children: Vec<Vec<usize>> = vec![Vec::new(); info.len()];
250 let mut roots: Vec<usize> = Vec::new();
251 for (i, (_, deps, _, _)) in info.iter().enumerate() {
252 match deps.first().and_then(|d| keys.iter().position(|k| k == d)) {
253 Some(parent) => children[parent].push(i),
254 None => roots.push(i),
255 }
256 }
257
258 struct Walker<'a> {
259 info: &'a [(String, Vec<String>, Status, Option<u64>)],
260 children: &'a [Vec<usize>],
261 seen: Vec<bool>,
262 rows: Vec<DagRow>,
263 }
264 impl Walker<'_> {
265 fn walk(&mut self, i: usize, guides: &str, last: bool, depth: usize) {
266 if self.seen[i] {
267 return;
268 }
269 self.seen[i] = true;
270 let (key, deps, status, dur) = &self.info[i];
271 let prefix = if depth == 0 {
272 String::new()
273 } else {
274 format!("{guides}{}", if last { "└─ " } else { "├─ " })
275 };
276 self.rows.push(DagRow {
277 prefix,
278 key: key.clone(),
279 status: status.clone(),
280 duration: *dur,
281 also_after: deps.iter().skip(1).cloned().collect(),
282 });
283 let next_guides = if depth == 0 {
284 String::new()
285 } else {
286 format!("{guides}{}", if last { " " } else { "│ " })
287 };
288 let kids = self.children[i].clone();
289 for (n, &c) in kids.iter().enumerate() {
290 self.walk(c, &next_guides, n + 1 == kids.len(), depth + 1);
291 }
292 }
293 }
294 let mut w = Walker {
295 info: &info,
296 children: &children,
297 seen: vec![false; info.len()],
298 rows: Vec::with_capacity(info.len()),
299 };
300 for &r in &roots {
301 w.walk(r, "", true, 0);
302 }
303 for i in 0..info.len() {
306 if !w.seen[i] {
307 w.walk(i, "", true, 0);
308 }
309 }
310 w.rows
311}
312
313pub async fn full_output(cli: &DatabricksCli, run_id: &str) -> (String, bool) {
319 let json = match cli.run(&["jobs", "get-run", run_id]).await {
320 Ok(v) => v,
321 Err(e) => return (format!("✗ {e:#}"), false),
322 };
323 let live = matches!(run_status(&json), Status::Running | Status::Pending);
324 let tasks: Vec<(String, String, Status)> = match json["tasks"].as_array() {
327 Some(ts) if !ts.is_empty() => ts
328 .iter()
329 .filter_map(|t| {
330 Some((
331 t["task_key"].as_str().unwrap_or("task").to_string(),
332 t["run_id"].as_u64()?.to_string(),
333 run_status(t),
334 ))
335 })
336 .collect(),
337 _ => vec![("run".to_string(), run_id.to_string(), run_status(&json))],
338 };
339
340 let mut out = String::new();
341 for (key, id, status) in &tasks {
342 if !out.is_empty() {
343 out.push('\n');
344 }
345 out.push_str(&format!("── {key} · {} ──\n", status.label()));
346 match cli.run(&["jobs", "get-run-output", id]).await {
347 Ok(o) => {
348 let mut wrote = false;
349 if let Some(err) = o["error"].as_str().filter(|s| !s.is_empty()) {
350 out.push_str(err.trim_end());
351 out.push('\n');
352 wrote = true;
353 }
354 if let Some(trace) = o["error_trace"].as_str().filter(|s| !s.is_empty()) {
355 out.push('\n');
356 out.push_str(trace.trim_end());
357 out.push('\n');
358 wrote = true;
359 }
360 if let Some(result) = o["notebook_output"]["result"]
361 .as_str()
362 .filter(|s| !s.is_empty())
363 {
364 out.push_str("notebook result: ");
365 out.push_str(result.trim_end());
366 out.push('\n');
367 wrote = true;
368 }
369 if let Some(logs) = o["logs"].as_str().filter(|s| !s.is_empty()) {
370 let tail: Vec<&str> = logs.lines().rev().take(200).collect();
372 out.push_str("logs (tail):\n");
373 for line in tail.iter().rev() {
374 out.push_str(line);
375 out.push('\n');
376 }
377 wrote = true;
378 }
379 if !wrote {
380 out.push_str("(no output recorded for this task)\n");
381 }
382 }
383 Err(e) => {
385 let msg = format!("{e:#}");
386 let first = msg.lines().next().unwrap_or("no output available");
387 out.push_str(&format!("({first})\n"));
388 }
389 }
390 }
391 (out, live)
392}
393
394pub async fn fetch(cli: &DatabricksCli, run_id: &str) -> (DetailData, bool) {
398 let args = ["jobs", "get-run", run_id];
399 let json = match cli.run(&args).await {
400 Ok(v) => v,
401 Err(e) => {
402 return (
403 DetailData {
404 summary: Vec::new(),
405 activity: Vec::new(),
406 raw: format!("✗ {e:#}"),
407 },
408 false,
409 )
410 }
411 };
412 let raw = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
413
414 let life = json["state"]["life_cycle_state"].as_str().unwrap_or("");
415 let result = json["state"]["result_state"].as_str().unwrap_or("");
416 let state_label = if result.is_empty() { life } else { result };
417 let status: Status = state_label.parse().unwrap();
418 let live = matches!(status, Status::Running | Status::Pending);
419
420 let mut summary = vec![("State".to_string(), state_label.to_string())];
421 if let Some(t) = json["start_time"].as_u64() {
422 summary.push(("Started".to_string(), relative_time(t)));
423 }
424 if let Some(d) = json["run_duration"]
425 .as_u64()
426 .or_else(|| json["execution_duration"].as_u64())
427 .filter(|d| *d > 0)
428 {
429 summary.push(("Duration".to_string(), fmt_duration_ms(d)));
430 }
431 if let Some(trigger) = json["trigger"].as_str() {
432 summary.push(("Trigger".to_string(), trigger.to_string()));
433 }
434 if let Some(msg) = json["state"]["state_message"]
435 .as_str()
436 .filter(|m| !m.is_empty())
437 {
438 summary.push(("Message".to_string(), msg.to_string()));
439 }
440
441 let mut activity: Vec<(Status, String)> = Vec::new();
444 let tasks = json["tasks"].as_array().cloned().unwrap_or_default();
445 for t in &tasks {
446 let key = t["task_key"].as_str().unwrap_or("?");
447 let t_status = run_status(t);
448 let dur = t["execution_duration"]
449 .as_u64()
450 .or_else(|| t["run_duration"].as_u64())
451 .filter(|d| *d > 0)
452 .map(|d| format!(" · {}", fmt_duration_ms(d)))
453 .unwrap_or_default();
454 let line = format!("{key} · {}{dur}", t_status.label());
455 let failed = matches!(t_status, Status::Failed);
456 activity.push((t_status, line));
457 if failed {
458 if let Some(task_run_id) = t["run_id"].as_u64() {
459 let id = task_run_id.to_string();
460 let out_args = ["jobs", "get-run-output", &id];
461 if let Ok(out) = cli.run(&out_args).await {
462 if let Some(err) = out["error"].as_str() {
463 let mut msg = err.replace('\n', " ");
464 if msg.chars().count() > 200 {
465 msg = msg.chars().take(200).collect::<String>() + "…";
466 }
467 activity.push((Status::Failed, format!(" ↳ {msg}")));
468 }
469 }
470 }
471 }
472 }
473 if activity.is_empty() {
474 activity.push((status, "single-task run — see raw for details".to_string()));
475 }
476
477 (
478 DetailData {
479 summary,
480 activity,
481 raw,
482 },
483 live,
484 )
485}
486
487#[cfg(test)]
488mod tests {
489 use super::timeline;
490 use crate::shape::Status;
491
492 #[test]
493 fn timeline_sorts_by_start_with_unstarted_last() {
494 let raw = r#"{"tasks":[
495 {"task_key":"b","start_time":2000,"end_time":5000,"state":{"result_state":"SUCCESS"}},
496 {"task_key":"c","start_time":0,"state":{"life_cycle_state":"BLOCKED"}},
497 {"task_key":"a","start_time":1000,"end_time":0,"state":{"life_cycle_state":"RUNNING"}}
498 ]}"#;
499 let ts = timeline(raw);
500 let keys: Vec<&str> = ts.iter().map(|t| t.key.as_str()).collect();
501 assert_eq!(keys, ["a", "b", "c"]);
502 assert_eq!(ts[0].end, None);
504 assert!(matches!(ts[0].status, Status::Running));
505 assert_eq!(ts[1].end, Some(5000));
506 assert_eq!(ts[2].start, 0);
507 }
508
509 #[test]
510 fn timeline_tolerates_non_json_and_taskless_runs() {
511 assert!(timeline("✗ boom").is_empty());
512 assert!(timeline("{}").is_empty());
513 }
514
515 #[test]
516 fn dag_places_tasks_under_first_dependency() {
517 let raw = r#"{"tasks":[
518 {"task_key":"extract","state":{"result_state":"SUCCESS"},"execution_duration":1000},
519 {"task_key":"transform","depends_on":[{"task_key":"extract"}],"state":{"result_state":"SUCCESS"}},
520 {"task_key":"load","depends_on":[{"task_key":"extract"}],"state":{"result_state":"RUNNING"}},
521 {"task_key":"report","depends_on":[{"task_key":"transform"},{"task_key":"load"}],"state":{"life_cycle_state":"BLOCKED"}}
522 ]}"#;
523 let rows = super::dag(raw);
524 let keys: Vec<&str> = rows.iter().map(|r| r.key.as_str()).collect();
525 assert_eq!(keys, ["extract", "transform", "report", "load"]);
526 assert_eq!(rows[0].prefix, "");
527 assert_eq!(rows[1].prefix, "├─ ");
528 assert_eq!(rows[2].prefix, "│ └─ ");
529 assert_eq!(rows[3].prefix, "└─ ");
530 assert_eq!(rows[2].also_after, ["load"]);
532 assert_eq!(rows[0].duration, Some(1000));
533 }
534
535 #[test]
536 fn grid_aligns_tasks_across_runs_oldest_first() {
537 let json = serde_json::json!([
539 {"run_id": 3, "start_time": 3000_u64, "state": {"result_state": "SUCCESS"},
540 "tasks": [
541 {"task_key": "extract", "execution_duration": 100, "state": {"result_state": "SUCCESS"}},
542 {"task_key": "load", "execution_duration": 50, "state": {"result_state": "FAILED"}}
543 ]},
544 {"run_id": 2, "start_time": 2000_u64, "state": {"result_state": "FAILED"},
545 "tasks": [
546 {"task_key": "extract", "execution_duration": 90, "state": {"result_state": "SUCCESS"}}
547 ]},
548 {"run_id": 1, "start_time": 1000_u64, "state": {"result_state": "SUCCESS"},
549 "tasks": [
550 {"task_key": "extract", "execution_duration": 80, "state": {"result_state": "SUCCESS"}}
551 ]}
552 ]);
553 let g = super::grid_from(&json);
554 let ids: Vec<&str> = g.runs.iter().map(|r| r.run_id.as_str()).collect();
555 assert_eq!(ids, ["1", "2", "3"]);
556 assert!(matches!(g.runs[1].status, Status::Failed));
557 assert_eq!(g.tasks[0].key, "extract");
559 assert_eq!(g.tasks[1].key, "load");
560 assert_eq!(g.tasks[0].durations, [Some(80), Some(90), Some(100)]);
561 assert!(g.tasks[1].cells[0].is_none());
563 assert!(g.tasks[1].cells[1].is_none());
564 assert!(matches!(g.tasks[1].cells[2], Some(Status::Failed)));
565 }
566
567 #[test]
568 fn grid_slowdown_flags_only_clear_regressions() {
569 let ok = Some(Status::Success);
570 let task = |durations: Vec<Option<u64>>| super::GridTask {
571 key: "t".to_string(),
572 cells: vec![ok.clone(); durations.len()],
573 durations,
574 };
575 let slow = task(vec![Some(100), Some(100), Some(100), Some(160)]);
577 assert_eq!(slow.slowdown(), Some((160, 100)));
578 assert_eq!(
580 task(vec![Some(100), Some(100), Some(100), Some(140)]).slowdown(),
581 None
582 );
583 assert_eq!(task(vec![Some(100), Some(100), Some(160)]).slowdown(), None);
585 let mut flaky = task(vec![Some(100), Some(10), Some(100), Some(100), Some(160)]);
587 flaky.cells[1] = Some(Status::Failed);
588 assert_eq!(flaky.slowdown(), Some((160, 100)));
589 }
590
591 #[test]
592 fn dag_tolerates_missing_parents_and_bad_json() {
593 assert!(super::dag("nope").is_empty());
594 let raw = r#"{"tasks":[
595 {"task_key":"orphan","depends_on":[{"task_key":"ghost"}],"state":{"result_state":"SUCCESS"}}
596 ]}"#;
597 let rows = super::dag(raw);
598 assert_eq!(rows.len(), 1);
599 assert_eq!(rows[0].key, "orphan");
600 assert_eq!(rows[0].prefix, "");
601 }
602}