1use crate::app::{App, Panel, ThemeMode};
2use crate::fetchers;
3use crate::shape::{DetailData, Shape, Status, TableData};
4use ratatui::{
5 layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
6 style::{Color, Modifier, Style},
7 text::{Line, Span},
8 widgets::{
9 Block, BorderType, Borders, Cell, Clear, List, ListItem, ListState, Padding, Paragraph,
10 Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, Wrap,
11 },
12 Frame,
13};
14use std::time::Duration;
15
16struct Palette {
17 text: Color,
18 dim: Color,
19 border: Color,
20 warn: Color,
21 ok: Color,
22 err: Color,
23 key: Color,
24 brand: Color,
25 clusters: Color,
26 jobs: Color,
27 pipelines: Color,
28 warehouses: Color,
29 catalog: Color,
30 grad_from: (u8, u8, u8),
32 grad_to: (u8, u8, u8),
33}
34
35const fn rgb(hex: u32) -> Color {
36 Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
37}
38
39const fn rgb3(hex: u32) -> (u8, u8, u8) {
40 ((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
41}
42
43fn palette(mode: ThemeMode) -> Palette {
44 match mode {
45 ThemeMode::Dark => Palette {
47 text: Color::White,
48 dim: Color::DarkGray,
49 border: Color::DarkGray,
50 warn: Color::Yellow,
51 ok: Color::Green,
52 err: Color::Red,
53 key: Color::Cyan,
54 brand: Color::Red,
55 clusters: Color::Cyan,
56 jobs: Color::Magenta,
57 pipelines: Color::Green,
58 warehouses: Color::Blue,
59 catalog: rgb(0xFF8C42),
60 grad_from: rgb3(0xFF3621),
61 grad_to: rgb3(0xFFA046),
62 },
63 ThemeMode::Light => Palette {
65 text: Color::Black,
66 dim: rgb(0x6B7280),
67 border: rgb(0x9CA3AF),
68 warn: rgb(0xB45309),
69 ok: rgb(0x15803D),
70 err: rgb(0xB91C1C),
71 key: rgb(0x0891B2),
72 brand: rgb(0xDC2626),
73 clusters: rgb(0x0891B2),
74 jobs: rgb(0xA21CAF),
75 pipelines: rgb(0x15803D),
76 warehouses: rgb(0x1D4ED8),
77 catalog: rgb(0xC2410C),
78 grad_from: rgb3(0xB91C1C),
79 grad_to: rgb3(0xC2410C),
80 },
81 ThemeMode::CatppuccinMocha => Palette {
82 text: rgb(0xCDD6F4),
83 dim: rgb(0x6C7086),
84 border: rgb(0x585B70),
85 warn: rgb(0xF9E2AF),
86 ok: rgb(0xA6E3A1),
87 err: rgb(0xF38BA8),
88 key: rgb(0x89DCEB),
89 brand: rgb(0xF38BA8),
90 clusters: rgb(0x89DCEB),
91 jobs: rgb(0xCBA6F7),
92 pipelines: rgb(0xA6E3A1),
93 warehouses: rgb(0x89B4FA),
94 catalog: rgb(0xFAB387),
95 grad_from: rgb3(0xF38BA8),
96 grad_to: rgb3(0xFAB387),
97 },
98 ThemeMode::CatppuccinMacchiato => Palette {
99 text: rgb(0xCAD3F5),
100 dim: rgb(0x6E738D),
101 border: rgb(0x5B6078),
102 warn: rgb(0xEED49F),
103 ok: rgb(0xA6DA95),
104 err: rgb(0xED8796),
105 key: rgb(0x91D7E3),
106 brand: rgb(0xED8796),
107 clusters: rgb(0x91D7E3),
108 jobs: rgb(0xC6A0F6),
109 pipelines: rgb(0xA6DA95),
110 warehouses: rgb(0x8AADF4),
111 catalog: rgb(0xF5A97F),
112 grad_from: rgb3(0xED8796),
113 grad_to: rgb3(0xF5A97F),
114 },
115 ThemeMode::CatppuccinFrappe => Palette {
116 text: rgb(0xC6D0F5),
117 dim: rgb(0x737994),
118 border: rgb(0x626880),
119 warn: rgb(0xE5C890),
120 ok: rgb(0xA6D189),
121 err: rgb(0xE78284),
122 key: rgb(0x99D1DB),
123 brand: rgb(0xE78284),
124 clusters: rgb(0x99D1DB),
125 jobs: rgb(0xCA9EE6),
126 pipelines: rgb(0xA6D189),
127 warehouses: rgb(0x8CAAEE),
128 catalog: rgb(0xEF9F76),
129 grad_from: rgb3(0xE78284),
130 grad_to: rgb3(0xEF9F76),
131 },
132 ThemeMode::CatppuccinLatte => Palette {
133 text: rgb(0x4C4F69),
134 dim: rgb(0x8C8FA1),
135 border: rgb(0xACB0BE),
136 warn: rgb(0xDF8E1D),
137 ok: rgb(0x40A02B),
138 err: rgb(0xD20F39),
139 key: rgb(0x04A5E5),
140 brand: rgb(0xD20F39),
141 clusters: rgb(0x04A5E5),
142 jobs: rgb(0x8839EF),
143 pipelines: rgb(0x40A02B),
144 warehouses: rgb(0x1E66F5),
145 catalog: rgb(0xFE640B),
146 grad_from: rgb3(0xD20F39),
147 grad_to: rgb3(0xFE640B),
148 },
149 ThemeMode::GruvboxDark => Palette {
150 text: rgb(0xEBDBB2),
151 dim: rgb(0x928374),
152 border: rgb(0x665C54),
153 warn: rgb(0xFABD2F),
154 ok: rgb(0xB8BB26),
155 err: rgb(0xFB4934),
156 key: rgb(0x8EC07C),
157 brand: rgb(0xFB4934),
158 clusters: rgb(0x8EC07C),
159 jobs: rgb(0xD3869B),
160 pipelines: rgb(0xB8BB26),
161 warehouses: rgb(0x83A598),
162 catalog: rgb(0xFE8019),
163 grad_from: rgb3(0xFB4934),
164 grad_to: rgb3(0xFE8019),
165 },
166 ThemeMode::GruvboxLight => Palette {
167 text: rgb(0x3C3836),
168 dim: rgb(0x928374),
169 border: rgb(0xBDAE93),
170 warn: rgb(0xB57614),
171 ok: rgb(0x79740E),
172 err: rgb(0x9D0006),
173 key: rgb(0x427B58),
174 brand: rgb(0x9D0006),
175 clusters: rgb(0x427B58),
176 jobs: rgb(0x8F3F71),
177 pipelines: rgb(0x79740E),
178 warehouses: rgb(0x076678),
179 catalog: rgb(0xAF3A03),
180 grad_from: rgb3(0x9D0006),
181 grad_to: rgb3(0xAF3A03),
182 },
183 ThemeMode::Dracula => Palette {
184 text: rgb(0xF8F8F2),
185 dim: rgb(0x6272A4),
186 border: rgb(0x44475A),
187 warn: rgb(0xF1FA8C),
188 ok: rgb(0x50FA7B),
189 err: rgb(0xFF5555),
190 key: rgb(0x8BE9FD),
191 brand: rgb(0xFF5555),
192 clusters: rgb(0x8BE9FD),
193 jobs: rgb(0xFF79C6),
194 pipelines: rgb(0x50FA7B),
195 warehouses: rgb(0xBD93F9),
196 catalog: rgb(0xFFB86C),
197 grad_from: rgb3(0xFF5555),
198 grad_to: rgb3(0xFFB86C),
199 },
200 ThemeMode::Nord => Palette {
201 text: rgb(0xD8DEE9),
202 dim: rgb(0x4C566A),
203 border: rgb(0x434C5E),
204 warn: rgb(0xEBCB8B),
205 ok: rgb(0xA3BE8C),
206 err: rgb(0xBF616A),
207 key: rgb(0x88C0D0),
208 brand: rgb(0xBF616A),
209 clusters: rgb(0x88C0D0),
210 jobs: rgb(0xB48EAD),
211 pipelines: rgb(0xA3BE8C),
212 warehouses: rgb(0x81A1C1),
213 catalog: rgb(0xD08770),
214 grad_from: rgb3(0xBF616A),
215 grad_to: rgb3(0xD08770),
216 },
217 ThemeMode::TokyoNight => Palette {
218 text: rgb(0xC0CAF5),
219 dim: rgb(0x565F89),
220 border: rgb(0x3B4261),
221 warn: rgb(0xE0AF68),
222 ok: rgb(0x9ECE6A),
223 err: rgb(0xF7768E),
224 key: rgb(0x7DCFFF),
225 brand: rgb(0xF7768E),
226 clusters: rgb(0x7DCFFF),
227 jobs: rgb(0xBB9AF7),
228 pipelines: rgb(0x9ECE6A),
229 warehouses: rgb(0x7AA2F7),
230 catalog: rgb(0xFF9E64),
231 grad_from: rgb3(0xF7768E),
232 grad_to: rgb3(0xFF9E64),
233 },
234 ThemeMode::RosePine => Palette {
235 text: rgb(0xE0DEF4),
236 dim: rgb(0x6E6A86),
237 border: rgb(0x403D52),
238 warn: rgb(0xF6C177),
239 ok: rgb(0x31748F),
240 err: rgb(0xEB6F92),
241 key: rgb(0x9CCFD8),
242 brand: rgb(0xEB6F92),
243 clusters: rgb(0x9CCFD8),
244 jobs: rgb(0xC4A7E7),
245 pipelines: rgb(0x31748F),
246 warehouses: rgb(0x3E8FB0),
247 catalog: rgb(0xEBBCBA),
248 grad_from: rgb3(0xEB6F92),
249 grad_to: rgb3(0xEBBCBA),
250 },
251 ThemeMode::Everforest => Palette {
252 text: rgb(0xD3C6AA),
253 dim: rgb(0x859289),
254 border: rgb(0x475258),
255 warn: rgb(0xDBBC7F),
256 ok: rgb(0xA7C080),
257 err: rgb(0xE67E80),
258 key: rgb(0x83C092),
259 brand: rgb(0xE67E80),
260 clusters: rgb(0x83C092),
261 jobs: rgb(0xD699B6),
262 pipelines: rgb(0xA7C080),
263 warehouses: rgb(0x7FBBB3),
264 catalog: rgb(0xE69875),
265 grad_from: rgb3(0xE67E80),
266 grad_to: rgb3(0xE69875),
267 },
268 ThemeMode::Kanagawa => Palette {
269 text: rgb(0xDCD7BA),
270 dim: rgb(0x727169),
271 border: rgb(0x54546D),
272 warn: rgb(0xE6C384),
273 ok: rgb(0x98BB6C),
274 err: rgb(0xC34043),
275 key: rgb(0x7FB4CA),
276 brand: rgb(0xC34043),
277 clusters: rgb(0x7FB4CA),
278 jobs: rgb(0x957FB8),
279 pipelines: rgb(0x98BB6C),
280 warehouses: rgb(0x7E9CD8),
281 catalog: rgb(0xFFA066),
282 grad_from: rgb3(0xC34043),
283 grad_to: rgb3(0xFFA066),
284 },
285 ThemeMode::SolarizedDark => Palette {
286 text: rgb(0x93A1A1),
287 dim: rgb(0x586E75),
288 border: rgb(0x073642),
289 warn: rgb(0xB58900),
290 ok: rgb(0x859900),
291 err: rgb(0xDC322F),
292 key: rgb(0x2AA198),
293 brand: rgb(0xDC322F),
294 clusters: rgb(0x2AA198),
295 jobs: rgb(0xD33682),
296 pipelines: rgb(0x859900),
297 warehouses: rgb(0x268BD2),
298 catalog: rgb(0xCB4B16),
299 grad_from: rgb3(0xDC322F),
300 grad_to: rgb3(0xCB4B16),
301 },
302 ThemeMode::OneDark => Palette {
303 text: rgb(0xABB2BF),
304 dim: rgb(0x5C6370),
305 border: rgb(0x3E4451),
306 warn: rgb(0xE5C07B),
307 ok: rgb(0x98C379),
308 err: rgb(0xE06C75),
309 key: rgb(0x56B6C2),
310 brand: rgb(0xE06C75),
311 clusters: rgb(0x56B6C2),
312 jobs: rgb(0xC678DD),
313 pipelines: rgb(0x98C379),
314 warehouses: rgb(0x61AFEF),
315 catalog: rgb(0xD19A66),
316 grad_from: rgb3(0xE06C75),
317 grad_to: rgb3(0xD19A66),
318 },
319 }
320}
321
322fn accent(panel: Panel, p: &Palette) -> Color {
323 match panel {
324 Panel::Clusters => p.clusters,
325 Panel::Jobs => p.jobs,
326 Panel::Pipelines => p.pipelines,
327 Panel::Warehouses => p.warehouses,
328 Panel::Dashboards => p.key,
329 Panel::Catalog => p.catalog,
330 Panel::Secrets => p.warn,
331 }
332}
333
334fn pane_breadcrumb(app: &App, panel: Panel) -> String {
335 match panel {
336 Panel::Catalog => app.uc_path.join("."),
337 Panel::Secrets => app.secret_scope.clone().unwrap_or_default(),
338 _ => String::new(),
339 }
340}
341
342pub fn draw(f: &mut Frame, app: &App) {
343 let p = palette(app.theme);
344
345 if app.splash_active() {
346 draw_splash(f, f.area(), app, &p);
347 return;
348 }
349
350 let root = Layout::default()
351 .direction(Direction::Vertical)
352 .constraints([
353 Constraint::Length(3),
354 Constraint::Min(0),
355 Constraint::Length(1),
356 ])
357 .split(f.area());
358
359 draw_header(f, root[0], app, &p);
360 draw_footer(f, root[2], app, &p);
361
362 if app.sql.is_some() {
363 draw_sql(f, root[1], app, &p);
364 if app.sql_complete.is_some() {
365 draw_sql_complete(f, root[1], app, &p);
366 }
367 if app.wh_picker.is_some() {
369 draw_wh_picker(f, root[1], app, &p);
370 }
371 return;
372 }
373
374 if app.cost.is_some() {
375 draw_cost(f, root[1], app, &p);
376 return;
377 }
378
379 if app.item_cost.is_some() {
380 draw_item_cost(f, root[1], app, &p);
381 return;
382 }
383
384 if app.job_health.is_some() {
385 draw_job_health(f, root[1], app, &p);
386 return;
387 }
388
389 if app.preview.is_some() {
390 draw_preview(f, root[1], app, &p);
391 return;
392 }
393
394 if app.run_view.is_some() {
395 draw_run(f, root[1], app, &p);
396 return;
397 }
398
399 if app.detail.is_some() {
400 draw_detail(f, root[1], app, &p);
401 return;
402 }
403
404 if app.zoomed {
405 let idx = Panel::ALL
406 .iter()
407 .position(|pn| pn == &app.focus)
408 .unwrap_or(0);
409 draw_panel(
410 f,
411 root[1],
412 app.focus,
413 app.shapes[idx].as_ref(),
414 true,
415 Some(app.selection(idx)),
416 false,
417 &pane_breadcrumb(app, app.focus),
418 app.spinner(),
419 &app.filters[idx],
420 app.filter_entry,
421 &app.pane_fav_set(idx),
422 app.fav_filter_active(idx),
423 &p,
424 );
425 if app.picker.is_some() {
427 draw_picker(f, root[1], app, &p);
428 }
429 if app.wh_picker.is_some() {
430 draw_wh_picker(f, root[1], app, &p);
431 }
432 if app.problems.is_some() {
433 draw_problems(f, root[1], app, &p);
434 }
435 if app.upcoming.is_some() {
436 draw_upcoming(f, root[1], app, &p);
437 }
438 if app.jump.is_some() {
439 draw_jump(f, root[1], app, &p);
440 }
441 if app.pane_cfg.is_some() {
442 draw_pane_cfg(f, root[1], app, &p);
443 }
444 if app.secret_form.is_some() {
445 draw_secret_form(f, root[1], app, &p);
446 }
447 if app.param_form.is_some() {
448 draw_param_form(f, root[1], app, &p);
449 }
450 if app.help {
451 draw_help(f, root[1], app, &p);
452 }
453 return;
454 }
455
456 let visible = app.visible_panes();
459 if visible.is_empty() {
460 let par = Paragraph::new("all panes hidden — press H to bring them back")
461 .style(Style::default().fg(p.dim))
462 .alignment(Alignment::Center);
463 f.render_widget(par, root[1]);
464 } else {
465 let rows_of = |n: usize| -> Vec<Constraint> {
466 (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect()
467 };
468 let left_count = visible.len().div_ceil(2);
469 let right_count = visible.len() - left_count;
470 let mut areas: Vec<Rect> = Vec::new();
471 if right_count == 0 {
472 areas.extend(
473 Layout::default()
474 .direction(Direction::Vertical)
475 .constraints(rows_of(left_count))
476 .split(root[1])
477 .iter(),
478 );
479 } else {
480 let body = Layout::default()
481 .direction(Direction::Horizontal)
482 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
483 .split(root[1]);
484 areas.extend(
485 Layout::default()
486 .direction(Direction::Vertical)
487 .constraints(rows_of(left_count))
488 .split(body[0])
489 .iter(),
490 );
491 areas.extend(
492 Layout::default()
493 .direction(Direction::Vertical)
494 .constraints(rows_of(right_count))
495 .split(body[1])
496 .iter(),
497 );
498 }
499
500 for (slot, &i) in visible.iter().enumerate() {
501 let panel = Panel::ALL[i];
502 let focused = app.focus == panel;
503 let shape = app.shapes[i].as_ref();
504 let selected = focused.then(|| app.selection(i));
505 let fresh = app.updated_at[i]
506 .map(|t| t.elapsed() < Duration::from_millis(1200))
507 .unwrap_or(false);
508 draw_panel(
509 f,
510 areas[slot],
511 panel,
512 shape,
513 focused,
514 selected,
515 fresh,
516 &pane_breadcrumb(app, panel),
517 app.spinner(),
518 &app.filters[i],
519 app.filter_entry && focused,
520 &app.pane_fav_set(i),
521 app.fav_filter_active(i),
522 &p,
523 );
524 }
525 }
526
527 if app.picker.is_some() {
528 draw_picker(f, root[1], app, &p);
529 }
530 if app.wh_picker.is_some() {
531 draw_wh_picker(f, root[1], app, &p);
532 }
533 if app.problems.is_some() {
534 draw_problems(f, root[1], app, &p);
535 }
536 if app.upcoming.is_some() {
537 draw_upcoming(f, root[1], app, &p);
538 }
539 if app.jump.is_some() {
540 draw_jump(f, root[1], app, &p);
541 }
542 if app.pane_cfg.is_some() {
543 draw_pane_cfg(f, root[1], app, &p);
544 }
545 if app.secret_form.is_some() {
546 draw_secret_form(f, root[1], app, &p);
547 }
548 if app.param_form.is_some() {
549 draw_param_form(f, root[1], app, &p);
550 }
551 if app.help {
552 draw_help(f, root[1], app, &p);
553 }
554}
555
556fn draw_secret_form(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
557 let Some(form) = &app.secret_form else {
558 return;
559 };
560 let width = 60.min(area.width.saturating_sub(4));
561 let height = 6.min(area.height);
562 let popup = Rect {
563 x: area.x + (area.width.saturating_sub(width)) / 2,
564 y: area.y + (area.height.saturating_sub(height)) / 2,
565 width,
566 height,
567 };
568 f.render_widget(Clear, popup);
569 let title = match &form.scope {
570 None => "New secret scope ".to_string(),
571 Some(s) => format!("New secret in {s} "),
572 };
573 let block = Block::default()
574 .title(Line::from(vec![
575 Span::styled(" ◈ ", Style::default().fg(p.warn)),
576 Span::styled(
577 title,
578 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
579 ),
580 ]))
581 .borders(Borders::ALL)
582 .border_type(BorderType::Thick)
583 .border_style(Style::default().fg(p.warn).add_modifier(Modifier::BOLD))
584 .padding(Padding::new(1, 1, 1, 0));
585 let label0 = if form.scope.is_none() {
586 "name "
587 } else {
588 "key "
589 };
590 let mut lines = vec![Line::from(vec![
591 Span::styled(label0, Style::default().fg(p.dim)),
592 Span::styled(form.key.as_str(), Style::default().fg(p.text)),
593 if form.stage == 0 {
594 Span::styled("▏", Style::default().fg(p.warn))
595 } else {
596 Span::raw("")
597 },
598 ])];
599 if form.scope.is_some() {
600 lines.push(Line::from(vec![
602 Span::styled("value ", Style::default().fg(p.dim)),
603 Span::styled(
604 "•".repeat(form.value.chars().count()),
605 Style::default().fg(p.text),
606 ),
607 if form.stage == 1 {
608 Span::styled("▏", Style::default().fg(p.warn))
609 } else {
610 Span::raw("")
611 },
612 ]));
613 }
614 f.render_widget(Paragraph::new(lines).block(block), popup);
615}
616
617fn draw_param_form(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
618 let Some(form) = &app.param_form else {
619 return;
620 };
621 let width = 70.min(area.width.saturating_sub(4));
622 let height = 6.min(area.height);
623 let popup = Rect {
624 x: area.x + (area.width.saturating_sub(width)) / 2,
625 y: area.y + (area.height.saturating_sub(height)) / 2,
626 width,
627 height,
628 };
629 f.render_widget(Clear, popup);
630 let block = Block::default()
631 .title(Line::from(vec![
632 Span::styled(" ▸ ", Style::default().fg(p.warn)),
633 Span::styled(
634 format!("Run “{}” with parameters ", form.job),
635 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
636 ),
637 ]))
638 .borders(Borders::ALL)
639 .border_type(BorderType::Thick)
640 .border_style(Style::default().fg(p.warn).add_modifier(Modifier::BOLD))
641 .padding(Padding::new(1, 1, 1, 0));
642 let lines = if form.loading {
643 vec![Line::from(Span::styled(
644 format!("{} loading current parameters…", app.spinner()),
645 Style::default().fg(p.warn),
646 ))]
647 } else {
648 let at = form
650 .input
651 .char_indices()
652 .nth(form.cursor)
653 .map(|(i, _)| i)
654 .unwrap_or(form.input.len());
655 let kind = match form.kind {
656 crate::fetchers::jobs::ParamKind::Job => "job parameters",
657 crate::fetchers::jobs::ParamKind::Notebook => "notebook parameters",
658 };
659 vec![
660 Line::from(vec![
661 Span::styled("❯ ", Style::default().fg(p.warn)),
662 Span::styled(&form.input[..at], Style::default().fg(p.text)),
663 Span::styled("▏", Style::default().fg(p.warn)),
664 Span::styled(&form.input[at..], Style::default().fg(p.text)),
665 ]),
666 Line::default(),
667 Line::from(Span::styled(
668 format!("key=value, comma-separated · sent as {kind} · empty runs as-is"),
669 Style::default().fg(p.dim),
670 )),
671 ]
672 };
673 f.render_widget(Paragraph::new(lines).block(block), popup);
674}
675
676fn draw_pane_cfg(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
677 let Some(selected) = app.pane_cfg else {
678 return;
679 };
680 let width = 52.min(area.width.saturating_sub(4));
681 let height = 11.min(area.height);
682 let popup = Rect {
683 x: area.x + (area.width.saturating_sub(width)) / 2,
684 y: area.y + (area.height.saturating_sub(height)) / 2,
685 width,
686 height,
687 };
688 f.render_widget(Clear, popup);
689 let block = Block::default()
690 .title(Line::from(vec![
691 Span::styled(" ▦ ", Style::default().fg(p.key)),
692 Span::styled(
693 "Panes ",
694 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
695 ),
696 ]))
697 .borders(Borders::ALL)
698 .border_type(BorderType::Thick)
699 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
700 .padding(Padding::new(1, 1, 1, 0));
701 let items: Vec<ListItem> = app
702 .pane_order
703 .iter()
704 .map(|&i| {
705 let panel = Panel::ALL[i];
706 let (mark, style) = if app.hidden[i] {
707 ("○ ", Style::default().fg(p.dim))
708 } else {
709 ("● ", Style::default().fg(p.ok))
710 };
711 ListItem::new(Line::from(vec![
712 Span::styled(mark, style),
713 Span::styled(
714 format!("{} ", panel.icon()),
715 Style::default().fg(accent(panel, p)),
716 ),
717 Span::styled(
718 panel.title(),
719 if app.hidden[i] {
720 Style::default().fg(p.dim)
721 } else {
722 Style::default().fg(p.text)
723 },
724 ),
725 ]))
726 })
727 .collect();
728 let list = List::new(items)
729 .block(block)
730 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
731 let mut state = ListState::default().with_selected(Some(selected));
732 f.render_stateful_widget(list, popup, &mut state);
733}
734
735fn draw_help(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
736 let width = 74.min(area.width.saturating_sub(4));
737 let height = area.height.saturating_sub(2);
738 let popup = Rect {
739 x: area.x + (area.width.saturating_sub(width)) / 2,
740 y: area.y + 1,
741 width,
742 height,
743 };
744 f.render_widget(Clear, popup);
745 let block = Block::default()
746 .title(Line::from(vec![
747 Span::styled(" ? ", Style::default().fg(p.key)),
748 Span::styled(
749 "Keys ",
750 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
751 ),
752 ]))
753 .borders(Borders::ALL)
754 .border_type(BorderType::Thick)
755 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
756 .padding(Padding::new(1, 1, 1, 0));
757
758 let sections: &[(&str, &[(&str, &str)])] = &[
759 (
760 "Global",
761 &[
762 ("tab / l, shift+tab / h", "focus next / previous pane"),
763 ("j / k", "select item"),
764 ("enter", "open details / drill down"),
765 ("/", "filter the focused pane (esc clears)"),
766 ("ctrl+p", "command palette: fuzzy-jump anywhere"),
767 ("!", "problems: failures in every workspace, enter jumps"),
768 ("u", "upcoming runs: what fires next, soonest first"),
769 (":", "SQL console (prefilled on a catalog table)"),
770 ("$", "cost view: DBUs, dollars, top spenders"),
771 ("H", "arrange panes: hide and reorder"),
772 ("z", "zoom the focused pane"),
773 ("w", "switch workspace profile"),
774 ("t", "cycle color theme"),
775 ("r", "refresh now"),
776 ("?", "this help"),
777 ("q / ctrl+c", "quit"),
778 ],
779 ),
780 (
781 "Focused pane",
782 &[
783 ("s", "start / stop / run the selected item"),
784 ("c", "jobs/pipelines: spend over week, month, quarter, year"),
785 (
786 "i",
787 "jobs: deep health report — trends, task attribution, compute pressure",
788 ),
789 ("p", "jobs: on the run confirm, edit parameters first"),
790 ("S", "jobs: pause / resume the schedule or trigger"),
791 ("f", "pin / unpin the selected item as a favorite"),
792 ("F", "show only favorites in the focused pane"),
793 ("g", "access: grants and permissions"),
794 ("o", "open in the workspace web UI"),
795 ],
796 ),
797 (
798 "Unity Catalog pane",
799 &[
800 ("enter / backspace", "drill down / up (into volumes too)"),
801 ("p / P", "preview table rows (P picks the warehouse)"),
802 ("L", "lineage: upstream and downstream tables"),
803 (":", "query the selected table"),
804 ("enter on a file", "peek at its first 200 lines"),
805 ],
806 ),
807 (
808 "Details & runs",
809 &[
810 ("enter", "job/pipeline detail → latest run/update"),
811 ("h / l", "older / newer run"),
812 ("o", "full task output — keeps tailing while live"),
813 ("t", "timeline: per-task Gantt of the run"),
814 ("d", "dag: task dependency tree of the run"),
815 ("g", "grid: task states across recent runs, with trends"),
816 ("r", "repair a failed run — reruns only failed tasks"),
817 ("W", "watch the run — bell + flash when it finishes"),
818 ("s", "cancel the shown run"),
819 ("j / k, J", "scroll · toggle raw JSON"),
820 ],
821 ),
822 (
823 "Table previews",
824 &[
825 ("j / k", "scroll rows"),
826 ("← / →", "page columns (wide tables)"),
827 ("/", "filter columns by name"),
828 ("v / enter", "record view: one row, fields stacked"),
829 ("e", "export rows to CSV"),
830 ],
831 ),
832 (
833 "Secret scopes pane",
834 &[
835 ("enter / backspace", "open a scope / back to scopes"),
836 ("a", "create a scope / add a secret (value masked)"),
837 ("x", "delete the selected scope or secret"),
838 ("g", "scope ACLs"),
839 ],
840 ),
841 (
842 "SQL console",
843 &[
844 ("enter", "run the statement"),
845 ("tab", "complete catalog/schema/table/column names"),
846 ("↑ / ↓", "history · walk fields one by one in record view"),
847 ("ctrl+r", "incremental history search"),
848 ("ctrl+x", "compose in $EDITOR"),
849 ("ctrl+s", "export results to CSV"),
850 ("ctrl+v / f2", "record view: one row, fields stacked"),
851 ("pgup / pgdn", "scroll results · page fields in record view"),
852 ("shift+← / →", "page result columns · rows in record view"),
853 (
854 "esc",
855 "cancel a running query, leave record view, else close",
856 ),
857 ],
858 ),
859 ];
860 let mut lines: Vec<Line> = Vec::new();
861 for (title, keys) in sections {
862 lines.push(Line::from(Span::styled(
863 *title,
864 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
865 )));
866 for (k, desc) in *keys {
867 lines.push(Line::from(vec![
868 Span::styled(format!(" {k:<24}"), Style::default().fg(p.warn)),
869 Span::styled(*desc, Style::default().fg(p.text)),
870 ]));
871 }
872 lines.push(Line::default());
873 }
874 let total = lines.len();
875 let par = Paragraph::new(lines)
876 .scroll((app.help_scroll, 0))
877 .block(block);
878 f.render_widget(par, popup);
879 scrollbar(
881 f,
882 popup,
883 total,
884 popup.height.saturating_sub(3) as usize,
885 app.help_scroll as usize,
886 p,
887 );
888}
889
890fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
891 let Some(jump) = &app.jump else {
892 return;
893 };
894 let matches = app.jump_matches();
895 let width = 70.min(area.width.saturating_sub(4));
896 let height = (matches.len() as u16 + 5).min(area.height);
897 let popup = Rect {
898 x: area.x + (area.width.saturating_sub(width)) / 2,
899 y: area.y + 2.min(area.height.saturating_sub(height)),
900 width,
901 height,
902 };
903 f.render_widget(Clear, popup);
904 let block = Block::default()
905 .title(Line::from(vec![
906 Span::styled(" ⌕ ", Style::default().fg(p.key)),
907 Span::styled(
908 "Jump to ",
909 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
910 ),
911 ]))
912 .borders(Borders::ALL)
913 .border_type(BorderType::Thick)
914 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
915 .padding(Padding::horizontal(1));
916 let inner = block.inner(popup);
917 f.render_widget(block, popup);
918
919 let parts = Layout::default()
920 .direction(Direction::Vertical)
921 .constraints([Constraint::Length(2), Constraint::Min(0)])
922 .split(inner);
923 f.render_widget(
924 Paragraph::new(Line::from(vec![
925 Span::styled("❯ ", Style::default().fg(p.key)),
926 Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
927 Span::styled("▏", Style::default().fg(p.warn)),
928 ])),
929 parts[0],
930 );
931
932 if matches.is_empty() {
933 f.render_widget(
934 Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
935 parts[1],
936 );
937 return;
938 }
939 let items: Vec<ListItem> = matches
940 .iter()
941 .map(|(panel_idx, name, label)| {
942 let panel = Panel::ALL[*panel_idx];
943 ListItem::new(Line::from(vec![
944 Span::styled(
945 format!("{} ", panel.icon()),
946 Style::default().fg(accent(panel, p)),
947 ),
948 Span::styled(format!("{name} "), Style::default().fg(p.text)),
949 Span::styled(label.clone(), Style::default().fg(p.dim)),
950 ]))
951 })
952 .collect();
953 let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
954 let mut state = ListState::default().with_selected(Some(jump.index));
955 f.render_stateful_widget(list, parts[1], &mut state);
956}
957
958fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
959 let Some(pr) = &app.problems else {
960 return;
961 };
962 let truncate = |s: &str, max: usize| -> String {
963 if s.chars().count() <= max {
964 s.to_string()
965 } else {
966 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
967 format!("{cut}…")
968 }
969 };
970 let prefix_of = |problem: &crate::app::Problem| -> String {
974 problem
975 .profile
976 .as_ref()
977 .map(|pr| format!("{pr} ▸ "))
978 .unwrap_or_default()
979 };
980 let name_w = pr
981 .items
982 .iter()
983 .map(|i| i.name.chars().count() + prefix_of(i).chars().count())
984 .max()
985 .unwrap_or(10)
986 .clamp(10, 40);
987 let note_w = pr
988 .items
989 .iter()
990 .map(|i| i.note.chars().count())
991 .max()
992 .unwrap_or(0);
993 let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
995 let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
996 let note_space = (width as usize).saturating_sub(name_w + 30);
998 let popup = Rect {
999 x: area.x + (area.width.saturating_sub(width)) / 2,
1000 y: area.y + (area.height.saturating_sub(height)) / 2,
1001 width,
1002 height,
1003 };
1004 f.render_widget(Clear, popup);
1005 let mut title_spans = vec![
1006 Span::styled(" ✗ ", Style::default().fg(p.err)),
1007 Span::styled(
1008 format!("Problems · {} ", pr.items.len()),
1009 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1010 ),
1011 ];
1012 if pr.scanning {
1013 title_spans.push(Span::styled(
1014 format!("· {} scanning workspaces ", app.spinner()),
1015 Style::default().fg(p.warn),
1016 ));
1017 }
1018 let block = Block::default()
1019 .title(Line::from(title_spans))
1020 .borders(Borders::ALL)
1021 .border_type(BorderType::Rounded)
1022 .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
1023 .padding(Padding::new(1, 1, 1, 1));
1024
1025 if pr.items.is_empty() {
1026 let (text, color) = if pr.scanning {
1027 (
1028 format!(
1029 "✓ nothing failing here — {} scanning other workspaces…",
1030 app.spinner()
1031 ),
1032 p.warn,
1033 )
1034 } else {
1035 ("✓ all clear — nothing failing right now".to_string(), p.ok)
1036 };
1037 let par = Paragraph::new(text)
1038 .style(Style::default().fg(color))
1039 .block(block);
1040 f.render_widget(par, popup);
1041 return;
1042 }
1043
1044 let items: Vec<ListItem> = pr
1045 .items
1046 .iter()
1047 .map(|problem| {
1048 let prefix = prefix_of(problem);
1049 let plen = prefix.chars().count().min(name_w.saturating_sub(4));
1050 let mut spans = vec![
1051 Span::styled("✗ ", Style::default().fg(p.err)),
1052 Span::styled(truncate(&prefix, plen), Style::default().fg(p.dim)),
1053 Span::styled(
1054 format!(
1055 "{:<width$}",
1056 truncate(&problem.name, name_w - plen),
1057 width = name_w + 2 - plen
1058 ),
1059 Style::default().fg(p.text),
1060 ),
1061 ];
1062 spans.push(match problem.panel {
1063 Some(i) => {
1064 let panel = Panel::ALL[i];
1065 Span::styled(
1066 format!("{} {:<18}", panel.icon(), panel.title()),
1067 Style::default().fg(accent(panel, p)),
1068 )
1069 }
1070 None => Span::styled(format!("⌂ {:<18}", "workspace"), Style::default().fg(p.dim)),
1071 });
1072 spans.push(Span::styled(
1073 truncate(&problem.note, note_space),
1074 Style::default().fg(p.dim),
1075 ));
1076 ListItem::new(Line::from(spans))
1077 })
1078 .collect();
1079 let list = List::new(items)
1080 .block(block)
1081 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1082 let mut state = ListState::default().with_selected(Some(pr.index));
1083 f.render_stateful_widget(list, popup, &mut state);
1084}
1085
1086fn draw_upcoming(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1089 let Some(u) = &app.upcoming else {
1090 return;
1091 };
1092 let label_w = u
1093 .items
1094 .iter()
1095 .map(|i| i.next.label.chars().count())
1096 .max()
1097 .unwrap_or(8)
1098 .clamp(8, 18);
1099 let name_w = u
1100 .items
1101 .iter()
1102 .map(|i| i.name.chars().count())
1103 .max()
1104 .unwrap_or(10)
1105 .clamp(10, 40);
1106 let desc_w = u
1107 .items
1108 .iter()
1109 .map(|i| i.next.desc.chars().count())
1110 .max()
1111 .unwrap_or(0);
1112 let width = ((label_w + name_w + desc_w + 12) as u16).min(area.width.saturating_sub(4));
1113 let height = (u.items.len().max(1) as u16 + 4).min(area.height);
1114 let desc_space = (width as usize).saturating_sub(label_w + name_w + 10);
1115 let popup = Rect {
1116 x: area.x + (area.width.saturating_sub(width)) / 2,
1117 y: area.y + (area.height.saturating_sub(height)) / 2,
1118 width,
1119 height,
1120 };
1121 f.render_widget(Clear, popup);
1122 let block = Block::default()
1123 .title(Line::from(vec![
1124 Span::styled(" ⏱ ", Style::default().fg(p.key)),
1125 Span::styled(
1126 format!("Upcoming runs · {} ", u.items.len()),
1127 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1128 ),
1129 ]))
1130 .borders(Borders::ALL)
1131 .border_type(BorderType::Rounded)
1132 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
1133 .padding(Padding::new(1, 1, 1, 1));
1134
1135 if u.loading {
1136 let par = Paragraph::new(format!("{} reading job schedules…", app.spinner()))
1137 .style(Style::default().fg(p.warn))
1138 .block(block);
1139 f.render_widget(par, popup);
1140 return;
1141 }
1142 if u.items.is_empty() {
1143 let par = Paragraph::new("no scheduled or triggered jobs — everything runs on demand")
1144 .style(Style::default().fg(p.dim))
1145 .block(block);
1146 f.render_widget(par, popup);
1147 return;
1148 }
1149
1150 let truncate = |s: &str, max: usize| -> String {
1151 if s.chars().count() <= max {
1152 s.to_string()
1153 } else {
1154 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
1155 format!("{cut}…")
1156 }
1157 };
1158 let items: Vec<ListItem> = u
1159 .items
1160 .iter()
1161 .map(|it| {
1162 let when_color = if it.next.paused {
1163 p.dim
1164 } else if it.next.at_ms.is_some() {
1165 p.ok
1166 } else {
1167 p.warn
1168 };
1169 ListItem::new(Line::from(vec![
1170 Span::styled(
1171 format!(
1172 "{:<width$}",
1173 truncate(&it.next.label, label_w),
1174 width = label_w + 2
1175 ),
1176 Style::default().fg(when_color),
1177 ),
1178 Span::styled(
1179 format!("{:<width$}", truncate(&it.name, name_w), width = name_w + 2),
1180 Style::default().fg(p.text),
1181 ),
1182 Span::styled(
1183 truncate(&it.next.desc, desc_space),
1184 Style::default().fg(p.dim),
1185 ),
1186 ]))
1187 })
1188 .collect();
1189 let list = List::new(items)
1190 .block(block)
1191 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1192 let mut state = ListState::default().with_selected(Some(u.index));
1193 f.render_stateful_widget(list, popup, &mut state);
1194}
1195
1196fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1197 let Some(picker) = &app.wh_picker else {
1198 return;
1199 };
1200 let warehouses = app.warehouses();
1201 let name_w = warehouses
1202 .iter()
1203 .map(|(n, _, _)| n.chars().count())
1204 .max()
1205 .unwrap_or(12)
1206 .max(12);
1207 let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
1209 let height = (warehouses.len() as u16 + 4).min(area.height);
1210 let popup = Rect {
1211 x: area.x + (area.width.saturating_sub(width)) / 2,
1212 y: area.y + (area.height.saturating_sub(height)) / 2,
1213 width,
1214 height,
1215 };
1216 f.render_widget(Clear, popup);
1217 let block = Block::default()
1218 .title(Line::from(vec![
1219 Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
1220 Span::styled(
1221 "Run query on ",
1222 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1223 ),
1224 ]))
1225 .borders(Borders::ALL)
1226 .border_type(BorderType::Rounded)
1227 .border_style(
1228 Style::default()
1229 .fg(p.warehouses)
1230 .add_modifier(Modifier::BOLD),
1231 )
1232 .padding(Padding::new(1, 1, 1, 1));
1233 let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
1234 let items: Vec<ListItem> = warehouses
1235 .iter()
1236 .map(|(name, id, running)| {
1237 let dot = if *running { p.ok } else { p.dim };
1238 let marker = if current_id == Some(id.as_str()) {
1239 "» "
1240 } else {
1241 " "
1242 };
1243 let state = if *running { "running" } else { "idle " };
1244 ListItem::new(Line::from(vec![
1245 Span::styled(marker, Style::default().fg(p.key)),
1246 Span::styled("● ", Style::default().fg(dot)),
1247 Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
1248 Span::styled(
1249 format!(" {state}"),
1250 Style::default().fg(if *running { p.ok } else { p.dim }),
1251 ),
1252 Span::styled(format!(" {id}"), Style::default().fg(p.dim)),
1253 ]))
1254 })
1255 .collect();
1256 let list = List::new(items)
1257 .block(block)
1258 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1259 let mut state = ListState::default().with_selected(Some(picker.index));
1260 f.render_stateful_widget(list, popup, &mut state);
1261}
1262
1263const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
1264const WORDMARK_BOT: &str = "█▄▀ █▀█ █ █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
1265
1266fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1267 let lines_total = 9u16;
1268 let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
1269 let frame = app.spinner_frame();
1270
1271 let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
1273 for (i, row) in bricks.iter().enumerate() {
1274 let line = Line::from(Span::styled(
1275 *row,
1276 Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
1277 ));
1278 let rect = Rect {
1279 x: area.x,
1280 y: top + i as u16,
1281 width: area.width,
1282 height: 1,
1283 };
1284 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1285 }
1286
1287 let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
1289 for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
1290 let spans: Vec<Span> = text
1291 .chars()
1292 .enumerate()
1293 .map(|(i, c)| {
1294 let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
1295 let style = if dist < 3 {
1296 Style::default()
1297 .fg(Color::White)
1298 .add_modifier(Modifier::BOLD)
1299 } else {
1300 Style::default().fg(p.brand)
1301 };
1302 Span::styled(c.to_string(), style)
1303 })
1304 .collect();
1305 let rect = Rect {
1306 x: area.x,
1307 y: top + row,
1308 width: area.width,
1309 height: 1,
1310 };
1311 f.render_widget(
1312 Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
1313 rect,
1314 );
1315 }
1316
1317 let tagline = Line::from(vec![
1318 Span::styled("the ", Style::default().fg(p.dim)),
1319 Span::styled(
1320 "Lakehouse",
1321 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1322 ),
1323 Span::styled(" in your terminal", Style::default().fg(p.dim)),
1324 ]);
1325 let version = Line::from(Span::styled(
1326 format!("v{}", env!("CARGO_PKG_VERSION")),
1327 Style::default().fg(p.dim),
1328 ));
1329 for (offset, line) in [(7u16, tagline), (8u16, version)] {
1330 let rect = Rect {
1331 x: area.x,
1332 y: top + offset,
1333 width: area.width,
1334 height: 1,
1335 };
1336 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1337 }
1338}
1339
1340fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1341 let selected = app.picker.unwrap_or(0);
1342 let width = (app
1343 .profiles
1344 .iter()
1345 .map(|s| s.len())
1346 .max()
1347 .unwrap_or(10)
1348 .max(20) as u16
1349 + 6)
1350 .min(area.width);
1351 let height = (app.profiles.len() as u16 + 2).min(area.height);
1352 let popup = Rect {
1353 x: area.x + (area.width.saturating_sub(width)) / 2,
1354 y: area.y + (area.height.saturating_sub(height)) / 2,
1355 width,
1356 height,
1357 };
1358 f.render_widget(Clear, popup);
1359 let block = Block::default()
1360 .title(Line::from(Span::styled(
1361 " ⌂ Workspace ",
1362 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1363 )))
1364 .borders(Borders::ALL)
1365 .border_type(BorderType::Rounded)
1366 .border_style(Style::default().fg(p.key))
1367 .padding(Padding::horizontal(1));
1368 let items: Vec<ListItem> = app
1369 .profiles
1370 .iter()
1371 .map(|name| {
1372 let current = app.profile.as_deref() == Some(name.as_str());
1373 let marker = if current { "● " } else { " " };
1374 ListItem::new(Line::from(vec![
1375 Span::styled(marker, Style::default().fg(p.ok)),
1376 Span::styled(name.as_str(), Style::default().fg(p.text)),
1377 ]))
1378 })
1379 .collect();
1380 let list = List::new(items)
1381 .block(block)
1382 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1383 let mut state = ListState::default().with_selected(Some(selected));
1384 f.render_stateful_widget(list, popup, &mut state);
1385}
1386
1387fn bucket_color(bucket: &str, p: &Palette) -> Color {
1388 match bucket {
1389 "Jobs" => p.jobs,
1390 "SQL" => p.warehouses,
1391 "All-Purpose" => p.clusters,
1392 "DLT" => p.pipelines,
1393 _ => p.dim,
1394 }
1395}
1396
1397fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1398 let cv = app.cost.as_ref().unwrap();
1399 let scope = match &cv.data {
1400 Some(Ok(d)) if d.scoped => " · this workspace",
1401 Some(Ok(_)) => " · all workspaces",
1402 _ => "",
1403 };
1404 let title = Line::from(vec![
1405 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1406 Span::styled(
1407 format!("Usage · last 14 days{scope} "),
1408 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1409 ),
1410 Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
1411 ]);
1412 let block = Block::default()
1413 .title(title)
1414 .borders(Borders::ALL)
1415 .border_type(BorderType::Rounded)
1416 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1417 .padding(Padding::new(1, 1, 1, 1));
1418
1419 match &cv.data {
1420 None => {
1421 let par = Paragraph::new(format!(
1422 "{} querying system.billing.usage — the warehouse may need to start…",
1423 app.spinner()
1424 ))
1425 .style(Style::default().fg(p.warn))
1426 .block(block);
1427 f.render_widget(par, area);
1428 }
1429 Some(Err(e)) => {
1430 let par = Paragraph::new(format!(
1431 "✗ {e}\n\nsystem tables need to be enabled and readable \
1432 (grants on the `system` catalog)"
1433 ))
1434 .style(Style::default().fg(p.err))
1435 .wrap(Wrap { trim: false })
1436 .block(block);
1437 f.render_widget(par, area);
1438 }
1439 Some(Ok(data)) if data.days.is_empty() => {
1440 let par = Paragraph::new("∅ no usage recorded in the last 14 days")
1441 .style(Style::default().fg(p.dim))
1442 .block(block);
1443 f.render_widget(par, area);
1444 }
1445 Some(Ok(data)) => {
1446 let mut lines: Vec<Line> = Vec::new();
1447
1448 let mut legend = vec![Span::raw("")];
1450 for (bucket, total, usd) in &data.buckets {
1451 legend.push(Span::styled(
1452 "■ ",
1453 Style::default().fg(bucket_color(bucket, p)),
1454 ));
1455 let amount = if data.priced {
1456 format!("{bucket} {total:.1} (${usd:.0}) ")
1457 } else {
1458 format!("{bucket} {total:.1} ")
1459 };
1460 legend.push(Span::styled(amount, Style::default().fg(p.text)));
1461 }
1462 let sigma = if data.priced {
1463 format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
1464 } else {
1465 format!("Σ {:.1} DBU", data.total)
1466 };
1467 legend.push(Span::styled(
1468 sigma,
1469 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1470 ));
1471 lines.push(Line::from(legend));
1472 if data.priced {
1473 lines.push(Line::from(Span::styled(
1474 " list prices before discounts",
1475 Style::default().fg(p.dim),
1476 )));
1477 }
1478 if !data.scoped {
1479 lines.push(Line::from(Span::styled(
1480 " couldn't resolve this workspace's id — showing the whole account",
1481 Style::default().fg(p.warn),
1482 )));
1483 }
1484 lines.push(Line::default());
1485
1486 let max_day = data
1487 .days
1488 .iter()
1489 .map(|d| d.total)
1490 .fold(0.0_f64, f64::max)
1491 .max(f64::EPSILON);
1492 let inner_w = area.width.saturating_sub(4) as usize;
1493 let bar_w = inner_w.saturating_sub(22).max(10);
1494
1495 for day in &data.days {
1496 let mut spans = vec![Span::styled(
1497 format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1499 Style::default().fg(p.dim),
1500 )];
1501 for (bucket, value) in &day.by_bucket {
1502 let chars = ((value / max_day) * bar_w as f64)
1503 .round()
1504 .max(if *value > 0.0 { 1.0 } else { 0.0 })
1505 as usize;
1506 spans.push(Span::styled(
1507 "█".repeat(chars),
1508 Style::default().fg(bucket_color(bucket, p)),
1509 ));
1510 }
1511 let day_label = if data.priced {
1512 format!(" {:.1} · ${:.2}", day.total, day.total_usd)
1513 } else {
1514 format!(" {:.1}", day.total)
1515 };
1516 spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1517 lines.push(Line::from(spans));
1518 }
1519
1520 if !data.spenders.is_empty() {
1521 lines.push(Line::default());
1522 lines.push(Line::from(Span::styled(
1523 "TOP SPENDERS · 14 days",
1524 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1525 )));
1526 let name_w = data
1527 .spenders
1528 .iter()
1529 .map(|s| {
1530 app.resource_name(&s.kind, &s.id)
1531 .unwrap_or_else(|| s.id.clone())
1532 .chars()
1533 .count()
1534 })
1535 .max()
1536 .unwrap_or(0)
1537 .max(8);
1538 for (i, s) in data.spenders.iter().enumerate() {
1539 let name = app
1540 .resource_name(&s.kind, &s.id)
1541 .unwrap_or_else(|| s.id.clone());
1542 let kind_color = match s.kind.as_str() {
1543 "job" => p.jobs,
1544 "cluster" => p.clusters,
1545 "warehouse" => p.warehouses,
1546 _ => p.dim,
1547 };
1548 let amount = if data.priced {
1549 format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1550 } else {
1551 format!("{:>8.1} DBU", s.dbus)
1552 };
1553 lines.push(Line::from(vec![
1554 Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1555 Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1556 Span::styled(
1557 format!("{:<width$}", name, width = name_w + 2),
1558 Style::default().fg(p.text),
1559 ),
1560 Span::styled(amount, Style::default().fg(p.text)),
1561 ]));
1562 }
1563 }
1564
1565 let par = Paragraph::new(lines).block(block);
1566 f.render_widget(par, area);
1567 }
1568 }
1569}
1570
1571fn month_bars(
1573 months: &[(String, f64, f64)],
1574 priced: bool,
1575 width: usize,
1576 p: &Palette,
1577) -> Vec<Line<'static>> {
1578 let max = months
1579 .iter()
1580 .map(|(_, dbus, _)| *dbus)
1581 .fold(0.0_f64, f64::max)
1582 .max(f64::EPSILON);
1583 let bar_w = width.saturating_sub(30).max(10);
1584 months
1585 .iter()
1586 .map(|(month, dbus, usd)| {
1587 let chars =
1588 ((dbus / max) * bar_w as f64)
1589 .round()
1590 .max(if *dbus > 0.0 { 1.0 } else { 0.0 }) as usize;
1591 let amount = if priced {
1592 format!(" {dbus:.1} · ${usd:.2}")
1593 } else {
1594 format!(" {dbus:.1}")
1595 };
1596 Line::from(vec![
1597 Span::styled(format!("{month:<9}"), Style::default().fg(p.dim)),
1598 Span::styled("█".repeat(chars), Style::default().fg(p.jobs)),
1599 Span::styled(amount, Style::default().fg(p.text)),
1600 ])
1601 })
1602 .collect()
1603}
1604
1605fn draw_item_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1606 let cv = app.item_cost.as_ref().unwrap();
1607 let scope = match &cv.data {
1608 Some(Ok(d)) if d.scoped => " · this workspace",
1609 Some(Ok(_)) => " · all workspaces",
1610 _ => "",
1611 };
1612 let title = Line::from(vec![
1613 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1614 Span::styled(
1615 format!("Spend · {} ", cv.name),
1616 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1617 ),
1618 Span::styled(
1619 format!("· {}{scope} · via {} ", cv.kind.label(), cv.warehouse),
1620 Style::default().fg(p.dim),
1621 ),
1622 ]);
1623 let block = Block::default()
1624 .title(title)
1625 .borders(Borders::ALL)
1626 .border_type(BorderType::Rounded)
1627 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1628 .padding(Padding::new(1, 1, 1, 1));
1629
1630 match &cv.data {
1631 None => {
1632 let par = Paragraph::new(format!(
1633 "{} querying system.billing.usage — the warehouse may need to start…",
1634 app.spinner()
1635 ))
1636 .style(Style::default().fg(p.warn))
1637 .block(block);
1638 f.render_widget(par, area);
1639 }
1640 Some(Err(e)) => {
1641 let par = Paragraph::new(format!(
1642 "✗ {e}\n\nsystem tables need to be enabled and readable \
1643 (grants on the `system` catalog)"
1644 ))
1645 .style(Style::default().fg(p.err))
1646 .wrap(Wrap { trim: false })
1647 .block(block);
1648 f.render_widget(par, area);
1649 }
1650 Some(Ok(data)) if data.is_empty() => {
1651 let hint = match data.kind {
1654 fetchers::cost::ResourceKind::Job => {
1655 "\n\nruns on all-purpose compute aren't attributed to a job id — \
1656 only job compute and serverless are"
1657 }
1658 fetchers::cost::ResourceKind::Pipeline => "",
1659 };
1660 let par = Paragraph::new(format!(
1661 "∅ no usage attributed to this {} in the last 365 days{hint}",
1662 data.kind.label()
1663 ))
1664 .style(Style::default().fg(p.dim))
1665 .wrap(Wrap { trim: false })
1666 .block(block);
1667 f.render_widget(par, area);
1668 }
1669 Some(Ok(data)) => {
1670 let mut lines: Vec<Line> = vec![Line::from(vec![
1671 Span::styled(
1672 format!("{:<14}{:>12}", "PERIOD", "DBU"),
1673 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1674 ),
1675 Span::styled(
1676 if data.priced {
1677 format!("{:>12}{:>14}", "USD", "VS PRIOR")
1678 } else {
1679 format!("{:>14}", "VS PRIOR")
1680 },
1681 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1682 ),
1683 ])];
1684 for w in &data.windows {
1685 let mut spans = vec![
1686 Span::styled(format!("{:<14}", w.label), Style::default().fg(p.text)),
1687 Span::styled(
1688 format!("{:>12.1}", w.dbus),
1689 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1690 ),
1691 ];
1692 if data.priced {
1693 spans.push(Span::styled(
1694 format!("{:>12}", format!("${:.2}", w.usd)),
1695 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1696 ));
1697 }
1698 let (trend, color) = match w.trend(data.priced) {
1700 Some(pct) if pct > 0.005 => (format!("▲ {:.0}%", pct * 100.0), p.warn),
1701 Some(pct) if pct < -0.005 => (format!("▼ {:.0}%", -pct * 100.0), p.ok),
1702 Some(_) => ("~ flat".to_string(), p.dim),
1703 None => ("—".to_string(), p.dim),
1704 };
1705 spans.push(Span::styled(
1706 format!("{trend:>14}"),
1707 Style::default().fg(color),
1708 ));
1709 lines.push(Line::from(spans));
1710 }
1711
1712 lines.push(Line::default());
1713 lines.push(Line::from(Span::styled(
1714 if data.priced {
1715 " list prices before discounts · trend compares with the preceding window"
1716 } else {
1717 " trend compares with the preceding window"
1718 },
1719 Style::default().fg(p.dim),
1720 )));
1721 if !data.scoped {
1722 lines.push(Line::from(Span::styled(
1723 " couldn't resolve this workspace's id — showing the whole account",
1724 Style::default().fg(p.warn),
1725 )));
1726 }
1727
1728 lines.push(Line::default());
1729 lines.push(Line::from(Span::styled(
1730 "BY MONTH",
1731 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1732 )));
1733 let inner_w = area.width.saturating_sub(4) as usize;
1734 lines.extend(month_bars(&data.months, data.priced, inner_w, p));
1735
1736 let par = Paragraph::new(lines).block(block);
1737 f.render_widget(par, area);
1738 }
1739 }
1740}
1741
1742fn humanize_bytes(bytes: i64) -> String {
1743 let bytes = bytes.max(0) as u64;
1744 match bytes {
1745 0..=1023 => format!("{bytes} B"),
1746 1024..=1_048_575 => format!("{:.1} KB", bytes as f64 / 1024.0),
1747 1_048_576..=1_073_741_823 => format!("{:.1} MB", bytes as f64 / 1_048_576.0),
1748 _ => format!("{:.2} GB", bytes as f64 / 1_073_741_824.0),
1749 }
1750}
1751
1752fn draw_job_health(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1753 let jh = app.job_health.as_ref().unwrap();
1754 let title = Line::from(vec![
1755 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1756 Span::styled(
1757 format!("Health · {} ", jh.job_name),
1758 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1759 ),
1760 Span::styled(
1761 format!(
1762 "· last {}d · via {} ",
1763 fetchers::job_health::WINDOW_DAYS,
1764 jh.warehouse
1765 ),
1766 Style::default().fg(p.dim),
1767 ),
1768 ]);
1769 let block = Block::default()
1770 .title(title)
1771 .borders(Borders::ALL)
1772 .border_type(BorderType::Rounded)
1773 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1774 .padding(Padding::new(1, 1, 1, 1));
1775
1776 match &jh.data {
1777 None => {
1778 let par = Paragraph::new(format!(
1779 "{} querying system.lakeflow — the warehouse may need to start…",
1780 app.spinner()
1781 ))
1782 .style(Style::default().fg(p.warn))
1783 .block(block);
1784 f.render_widget(par, area);
1785 return;
1786 }
1787 Some(Err(e)) => {
1788 let par = Paragraph::new(format!(
1789 "✗ {e}\n\nsystem tables need to be enabled and readable \
1790 (grants on the `system` catalog)"
1791 ))
1792 .style(Style::default().fg(p.err))
1793 .wrap(Wrap { trim: false })
1794 .block(block);
1795 f.render_widget(par, area);
1796 return;
1797 }
1798 Some(Ok(data)) if data.days.is_empty() => {
1799 let par = Paragraph::new(format!(
1800 "∅ no runs in the last {} days",
1801 fetchers::job_health::WINDOW_DAYS
1802 ))
1803 .style(Style::default().fg(p.dim))
1804 .block(block);
1805 f.render_widget(par, area);
1806 return;
1807 }
1808 Some(Ok(_)) => {}
1809 }
1810 let Some(Ok(data)) = &jh.data else {
1811 unreachable!("handled above")
1812 };
1813
1814 let mut lines: Vec<Line> = Vec::new();
1815
1816 let rate_color = if data.success_rate >= 95.0 {
1818 p.ok
1819 } else if data.success_rate >= 80.0 {
1820 p.warn
1821 } else {
1822 p.err
1823 };
1824 let mut summary = vec![
1825 Span::styled(
1826 format!("Σ {} runs", data.total_runs),
1827 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1828 ),
1829 Span::styled(
1830 format!(
1831 " · {} success",
1832 fetchers::job_health::fmt_success_rate(data.success_rate)
1833 ),
1834 Style::default().fg(rate_color).add_modifier(Modifier::BOLD),
1835 ),
1836 Span::styled(
1837 format!(" · {} failed ", data.failed_runs),
1838 Style::default().fg(p.dim),
1839 ),
1840 ];
1841 let (trend, trend_color) = match data.duration_trend_pct {
1842 Some(pct) if pct > 5.0 => (format!("▲ {pct:.0}% slower"), p.warn),
1843 Some(pct) if pct < -5.0 => (format!("▼ {:.0}% faster", -pct), p.ok),
1844 Some(_) => ("~ duration flat".to_string(), p.dim),
1845 None => ("— not enough runs for a duration trend".to_string(), p.dim),
1846 };
1847 summary.push(Span::styled(trend, Style::default().fg(trend_color)));
1848 lines.push(Line::from(summary));
1849 if !data.scoped {
1850 lines.push(Line::from(Span::styled(
1851 " couldn't resolve this workspace's id — showing the whole account",
1852 Style::default().fg(p.warn),
1853 )));
1854 }
1855 lines.push(Line::default());
1856
1857 let max_day = data
1859 .days
1860 .iter()
1861 .map(|d| d.success + d.failed + d.other)
1862 .max()
1863 .unwrap_or(0)
1864 .max(1) as f64;
1865 let inner_w = area.width.saturating_sub(4) as usize;
1866 let bar_w = inner_w.saturating_sub(16).max(10);
1867 for day in &data.days {
1868 let mut spans = vec![Span::styled(
1869 format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1870 Style::default().fg(p.dim),
1871 )];
1872 let seg = |n: u32| -> usize {
1873 ((n as f64 / max_day) * bar_w as f64)
1874 .round()
1875 .max(if n > 0 { 1.0 } else { 0.0 }) as usize
1876 };
1877 if day.success > 0 {
1878 spans.push(Span::styled(
1879 "█".repeat(seg(day.success)),
1880 Style::default().fg(p.ok),
1881 ));
1882 }
1883 if day.failed > 0 {
1884 spans.push(Span::styled(
1885 "█".repeat(seg(day.failed)),
1886 Style::default().fg(p.err),
1887 ));
1888 }
1889 if day.other > 0 {
1890 spans.push(Span::styled(
1891 "█".repeat(seg(day.other)),
1892 Style::default().fg(p.dim),
1893 ));
1894 }
1895 spans.push(Span::styled(
1896 format!(" {}/{}", day.success, day.success + day.failed + day.other),
1897 Style::default().fg(p.text),
1898 ));
1899 lines.push(Line::from(spans));
1900 }
1901 lines.push(Line::default());
1902
1903 lines.push(Line::from(Span::styled(
1905 "TASK FAILURES",
1906 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1907 )));
1908 if !data.task_attribution_available {
1909 lines.push(Line::from(Span::styled(
1910 "○ task-level attribution unavailable — system.lakeflow.job_task_run_timeline not readable",
1911 Style::default().fg(p.dim),
1912 )));
1913 } else if data.task_failures.is_empty() {
1914 lines.push(Line::from(Span::styled(
1915 "✓ no task failures in this window",
1916 Style::default().fg(p.ok),
1917 )));
1918 } else {
1919 let name_w = data
1923 .task_failures
1924 .iter()
1925 .map(|t| t.task_key.chars().count())
1926 .max()
1927 .unwrap_or(0);
1928 for t in &data.task_failures {
1929 lines.push(Line::from(vec![
1930 Span::styled(
1931 format!(" {:<width$}", t.task_key, width = name_w + 2),
1932 Style::default().fg(p.text),
1933 ),
1934 Span::styled(
1935 format!("{} / {} runs failed", t.failures, t.total),
1936 Style::default().fg(p.err),
1937 ),
1938 ]));
1939 }
1940 }
1941 lines.push(Line::default());
1942
1943 lines.push(Line::from(Span::styled(
1945 "COMPUTE",
1946 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1947 )));
1948 match &data.compute {
1949 None => lines.push(Line::from(Span::styled(
1950 "○ compute pressure unavailable — system.compute schema not enabled/readable",
1951 Style::default().fg(p.dim),
1952 ))),
1953 Some(c) => {
1954 lines.push(Line::from(Span::styled(
1955 format!(
1956 " cpu busy avg {:.0}% cpu wait avg {:.0}% mem avg {:.0}% mem p90 {:.0}%",
1957 c.avg_cpu_busy_pct, c.avg_cpu_wait_pct, c.avg_mem_used_pct, c.p90_mem_used_pct
1958 ),
1959 Style::default().fg(p.text),
1960 )));
1961 if let Some(family) = data.node_family {
1962 let label = match family {
1963 fetchers::job_health::NodeFamily::Memory => "memory-optimized",
1964 fetchers::job_health::NodeFamily::Compute => "compute-optimized",
1965 fetchers::job_health::NodeFamily::General => "general-purpose",
1966 fetchers::job_health::NodeFamily::Unknown => "unrecognized",
1967 };
1968 lines.push(Line::from(Span::styled(
1969 format!(" node family: {label} (best guess from the instance type name)"),
1970 Style::default().fg(p.dim),
1971 )));
1972 }
1973 }
1974 }
1975 lines.push(Line::default());
1976
1977 lines.push(Line::from(Span::styled(
1981 "FLAGS",
1982 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1983 )));
1984 let cross_flags = match &jh.live {
1985 Some(Ok(live)) => fetchers::job_health::cross_signal_flags(data, live),
1986 _ => Vec::new(),
1987 };
1988 let all_flags: Vec<&fetchers::job_health::HealthFlag> =
1989 data.flags.iter().chain(cross_flags.iter()).collect();
1990 if all_flags.is_empty() {
1991 lines.push(Line::from(Span::styled(
1992 "✓ nothing flagged",
1993 Style::default().fg(p.ok),
1994 )));
1995 } else {
1996 for flag in all_flags {
1997 let (glyph, color) = match flag.severity {
1998 fetchers::job_health::FlagSeverity::Critical => ("✗", p.err),
1999 fetchers::job_health::FlagSeverity::Warn => ("▲", p.warn),
2000 fetchers::job_health::FlagSeverity::Info => ("·", p.dim),
2001 };
2002 lines.push(Line::from(Span::styled(
2003 format!("{glyph} {}", flag.message),
2004 Style::default().fg(color),
2005 )));
2006 }
2007 }
2008 lines.push(Line::default());
2009
2010 lines.push(Line::from(Span::styled(
2014 "SPARK DIAGNOSTICS",
2015 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
2016 )));
2017 match &jh.live {
2018 None => lines.push(Line::from(Span::styled(
2019 format!("{} checking the most recent run's driver…", app.spinner()),
2020 Style::default().fg(p.dim),
2021 ))),
2022 Some(Err(e)) => lines.push(Line::from(Span::styled(
2023 format!("○ Spark diagnostics unavailable — {e}"),
2024 Style::default().fg(p.dim),
2025 ))),
2026 Some(Ok(live)) if live.stages.is_empty() => lines.push(Line::from(Span::styled(
2027 "○ no stage data reported for this run",
2028 Style::default().fg(p.dim),
2029 ))),
2030 Some(Ok(live)) => {
2031 let source = match live.source {
2032 fetchers::spark_live::DiagSource::Live => "live driver",
2033 fetchers::spark_live::DiagSource::EventLog => "delivered event log",
2034 };
2035 lines.push(Line::from(Span::styled(
2036 format!(
2037 " run {} · app {} · {} stage{} · via {source}",
2038 live.run_id,
2039 live.app_id,
2040 live.stages.len(),
2041 if live.stages.len() == 1 { "" } else { "s" }
2042 ),
2043 Style::default().fg(p.dim),
2044 )));
2045 for s in &live.stages {
2046 lines.push(Line::default());
2047 lines.push(Line::from(vec![
2048 Span::styled(format!(" #{} ", s.stage_id), Style::default().fg(p.dim)),
2049 Span::styled(
2050 s.name.clone(),
2051 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2052 ),
2053 ]));
2054 let (skew_note, skew_color) = if s.skew_ratio > 0.0 {
2055 (
2056 format!(
2057 " skew {:.1}× — slowest task {}ms vs median {}ms across {} tasks",
2058 s.skew_ratio,
2059 s.max_task_duration_ms,
2060 s.median_task_duration_ms,
2061 s.num_tasks
2062 ),
2063 if s.skew_ratio >= 3.0 { p.warn } else { p.dim },
2064 )
2065 } else {
2066 (
2067 format!(" {} task(s), too few to judge skew", s.num_tasks),
2068 p.dim,
2069 )
2070 };
2071 lines.push(Line::from(Span::styled(
2072 skew_note,
2073 Style::default().fg(skew_color),
2074 )));
2075 let spill = s.memory_bytes_spilled + s.disk_bytes_spilled;
2076 let (spill_note, spill_color) = if spill > 0 {
2077 (
2078 format!(
2079 " spill {} — memory {} / disk {}",
2080 humanize_bytes(spill),
2081 humanize_bytes(s.memory_bytes_spilled),
2082 humanize_bytes(s.disk_bytes_spilled)
2083 ),
2084 p.warn,
2085 )
2086 } else {
2087 (" no spill".to_string(), p.dim)
2088 };
2089 lines.push(Line::from(Span::styled(
2090 spill_note,
2091 Style::default().fg(spill_color),
2092 )));
2093 }
2094 }
2095 }
2096
2097 let par = Paragraph::new(lines)
2098 .wrap(Wrap { trim: false })
2099 .scroll((jh.scroll, 0))
2100 .block(block);
2101 f.render_widget(par, area);
2102}
2103
2104fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2105 let console = app.sql.as_ref().unwrap();
2106 let parts = Layout::default()
2107 .direction(Direction::Vertical)
2108 .constraints([Constraint::Length(3), Constraint::Min(0)])
2109 .split(area);
2110
2111 let via = if console.warehouse.is_empty() {
2112 String::new()
2113 } else {
2114 format!("via {} ", console.warehouse)
2115 };
2116 let prompt_block = Block::default()
2117 .title(Line::from(vec![
2118 Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
2119 Span::styled(
2120 "SQL Console ",
2121 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2122 ),
2123 Span::styled(via, Style::default().fg(p.dim)),
2124 ]))
2125 .borders(Borders::ALL)
2126 .border_type(BorderType::Thick)
2127 .border_style(
2128 Style::default()
2129 .fg(p.warehouses)
2130 .add_modifier(Modifier::BOLD),
2131 )
2132 .padding(Padding::horizontal(1));
2133 if let Some((query, _)) = &app.hist_search {
2134 let matched = app
2136 .hist_search_current()
2137 .map(|s| s.replace('\n', " "))
2138 .unwrap_or_default();
2139 let mut spans = vec![
2140 Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
2141 Span::styled(
2142 query.as_str(),
2143 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2144 ),
2145 Span::styled("`: ", Style::default().fg(p.dim)),
2146 ];
2147 spans.extend(sql_spans(&matched, p));
2148 let prompt = Paragraph::new(Line::from(spans)).block(prompt_block);
2149 f.render_widget(prompt, parts[0]);
2150 } else {
2151 let caret_byte = console
2155 .input
2156 .char_indices()
2157 .nth(console.cursor)
2158 .map(|(i, _)| i)
2159 .unwrap_or(console.input.len());
2160 let (before, after) = console.input.split_at(caret_byte);
2161 let inner_w = parts[0].width.saturating_sub(4) as usize; let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
2163 let mut spans = vec![Span::styled("❯ ", Style::default().fg(p.key))];
2166 spans.extend(sql_spans(&before.replace('\n', " "), p));
2167 spans.push(Span::styled("▏", Style::default().fg(p.warn)));
2168 spans.extend(sql_spans(&after.replace('\n', " "), p));
2169 let prompt = Paragraph::new(Line::from(spans))
2170 .scroll((0, hscroll))
2171 .block(prompt_block);
2172 f.render_widget(prompt, parts[0]);
2173 }
2174
2175 let row_info = match &console.data {
2176 Some(Ok(t)) => format!("{} rows ", t.rows.len()),
2177 _ => String::new(),
2178 };
2179 let mut results_title = vec![
2180 Span::styled(" Results ", Style::default().fg(p.text)),
2181 Span::styled(row_info, Style::default().fg(p.dim)),
2182 ];
2183 let avail = parts[1].width.saturating_sub(4) as usize;
2185 let sliced = match &console.data {
2186 Some(Ok(t)) if console.record && !t.rows.is_empty() => {
2187 let row_n = console.scroll.min(t.rows.len().saturating_sub(1));
2188 results_title.push(Span::styled(
2189 format!("· row {}/{} (⇧←/→) ", row_n + 1, t.rows.len()),
2190 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2191 ));
2192 None
2193 }
2194 Some(Ok(t)) if !t.headers.is_empty() => {
2195 let cols: Vec<usize> = (0..t.headers.len()).collect();
2196 let (shown, constraints) = grid_slice(t, &cols, console.col, avail);
2197 if shown.len() < cols.len() {
2198 let first = console.col.min(cols.len() - 1) + 1;
2199 results_title.push(Span::styled(
2200 format!(
2201 "· cols {}–{} of {} (⇧←/→) ",
2202 first,
2203 first + shown.len() - 1,
2204 cols.len()
2205 ),
2206 Style::default().fg(p.dim),
2207 ));
2208 }
2209 Some((shown, constraints))
2210 }
2211 _ => None,
2212 };
2213 let results_block = Block::default()
2214 .title(Line::from(results_title))
2215 .borders(Borders::ALL)
2216 .border_type(BorderType::Rounded)
2217 .border_style(Style::default().fg(p.border))
2218 .padding(Padding::horizontal(1));
2219
2220 if console.running {
2221 let par = Paragraph::new(format!(
2222 "{} running on {} — the warehouse may need a moment to start…",
2223 app.spinner(),
2224 console.warehouse
2225 ))
2226 .style(Style::default().fg(p.warn))
2227 .block(results_block);
2228 f.render_widget(par, parts[1]);
2229 return;
2230 }
2231 match &console.data {
2232 None => {
2233 let par = Paragraph::new(
2234 "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
2235 )
2236 .style(Style::default().fg(p.dim))
2237 .block(results_block);
2238 f.render_widget(par, parts[1]);
2239 }
2240 Some(Err(e)) => {
2241 let par = Paragraph::new(format!("✗ {e}"))
2242 .style(Style::default().fg(p.err))
2243 .wrap(Wrap { trim: false })
2244 .block(results_block);
2245 f.render_widget(par, parts[1]);
2246 }
2247 Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
2248 let par = Paragraph::new("✓ statement succeeded — no result set")
2249 .style(Style::default().fg(p.ok))
2250 .block(results_block);
2251 f.render_widget(par, parts[1]);
2252 }
2253 Some(Ok(data)) if console.record && !data.rows.is_empty() => {
2256 let row_n = console.scroll.min(data.rows.len().saturating_sub(1));
2257 let name_w = data
2258 .headers
2259 .iter()
2260 .map(|h| h.chars().count())
2261 .max()
2262 .unwrap_or(0)
2263 .min(32);
2264 let empty: Vec<String> = Vec::new();
2265 let row = data.rows.get(row_n).unwrap_or(&empty);
2266 let lines: Vec<Line> = data
2267 .headers
2268 .iter()
2269 .enumerate()
2270 .map(|(i, header)| {
2271 let value = row.get(i).map(String::as_str).unwrap_or("");
2272 let vstyle = if value == "␀" {
2273 Style::default().fg(p.dim)
2274 } else {
2275 Style::default().fg(p.text)
2276 };
2277 Line::from(vec![
2278 Span::styled(
2279 format!("{header:<name_w$} "),
2280 Style::default().fg(p.warehouses),
2281 ),
2282 Span::styled(value.to_string(), vstyle),
2283 ])
2284 })
2285 .collect();
2286 let total = lines.len();
2287 let par = Paragraph::new(lines)
2288 .scroll((console.rscroll, 0))
2289 .block(results_block);
2290 f.render_widget(par, parts[1]);
2291 scrollbar(
2292 f,
2293 parts[1],
2294 total,
2295 parts[1].height.saturating_sub(2) as usize,
2296 console.rscroll as usize,
2297 p,
2298 );
2299 }
2300 Some(Ok(data)) => {
2301 let (shown, constraints) = sliced.unwrap_or_default();
2302 let header_cells: Vec<Cell> = shown
2303 .iter()
2304 .map(|&i| {
2305 Cell::from(data.headers[i].as_str()).style(
2306 Style::default()
2307 .fg(p.warehouses)
2308 .add_modifier(Modifier::BOLD),
2309 )
2310 })
2311 .collect();
2312 let header = Row::new(header_cells);
2313 let rows: Vec<Row> = data
2314 .rows
2315 .iter()
2316 .skip(console.scroll)
2317 .map(|r| {
2318 Row::new(
2319 shown
2320 .iter()
2321 .map(|&i| {
2322 Cell::from(r.get(i).map(String::as_str).unwrap_or(""))
2323 .style(Style::default().fg(p.text))
2324 })
2325 .collect::<Vec<_>>(),
2326 )
2327 })
2328 .collect();
2329 let table = Table::new(rows, constraints)
2330 .header(header)
2331 .column_spacing(1)
2332 .block(results_block);
2333 f.render_widget(table, parts[1]);
2334 scrollbar(
2336 f,
2337 parts[1],
2338 data.rows.len(),
2339 parts[1].height.saturating_sub(3) as usize,
2340 console.scroll,
2341 p,
2342 );
2343 }
2344 }
2345}
2346
2347fn draw_sql_complete(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2349 let (Some(console), Some(comp)) = (&app.sql, &app.sql_complete) else {
2350 return;
2351 };
2352 let inner_w = area.width.saturating_sub(4) as usize;
2354 let hscroll = (console.cursor + 3).saturating_sub(inner_w);
2355 let anchor = (area.x as usize + 4 + comp.seg_start.saturating_sub(hscroll)) as u16;
2356
2357 let lines: Vec<Line> = if comp.loading {
2358 vec![Line::from(Span::styled(
2359 format!("{} fetching names…", app.spinner()),
2360 Style::default().fg(p.warn),
2361 ))]
2362 } else {
2363 let visible = 8.min(comp.items.len());
2365 let skip = comp.index.saturating_sub(visible.saturating_sub(1));
2366 comp.items
2367 .iter()
2368 .enumerate()
2369 .skip(skip)
2370 .take(visible)
2371 .map(|(i, item)| {
2372 let style = if i == comp.index {
2373 Style::default()
2374 .fg(p.key)
2375 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
2376 } else {
2377 Style::default().fg(p.text)
2378 };
2379 Line::from(Span::styled(format!(" {item} "), style))
2380 })
2381 .collect()
2382 };
2383 let widest = comp
2384 .items
2385 .iter()
2386 .map(|i| i.chars().count())
2387 .max()
2388 .unwrap_or(16);
2389 let width = (widest as u16 + 4).clamp(20, 44).min(area.width);
2390 let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(3));
2391 let popup = Rect {
2392 x: anchor.min(area.x + area.width.saturating_sub(width)),
2393 y: area.y + 3,
2394 width,
2395 height,
2396 };
2397 let title = if comp.loading {
2398 String::new()
2399 } else {
2400 format!(" {}/{} ", comp.index + 1, comp.items.len())
2401 };
2402 let block = Block::default()
2403 .title(Span::styled(title, Style::default().fg(p.dim)))
2404 .borders(Borders::ALL)
2405 .border_type(BorderType::Rounded)
2406 .border_style(Style::default().fg(p.key));
2407 f.render_widget(Clear, popup);
2408 f.render_widget(Paragraph::new(lines).block(block), popup);
2409}
2410
2411fn grid_widths(data: &TableData, cols: &[usize]) -> Vec<u16> {
2414 cols.iter()
2415 .map(|&i| {
2416 let mut w = data.headers.get(i).map(|h| h.chars().count()).unwrap_or(0);
2417 for row in data.rows.iter().take(50) {
2418 if let Some(c) = row.get(i) {
2419 w = w.max(c.chars().count());
2420 }
2421 }
2422 w.clamp(4, 36) as u16
2423 })
2424 .collect()
2425}
2426
2427fn grid_slice(
2432 data: &TableData,
2433 cols: &[usize],
2434 col: usize,
2435 avail: usize,
2436) -> (Vec<usize>, Vec<Constraint>) {
2437 let widths = grid_widths(data, cols);
2438 let col = col.min(cols.len().saturating_sub(1));
2439 let mut take = 0usize;
2440 let mut used = 0usize;
2441 for w in widths.iter().skip(col) {
2442 let next = used + *w as usize + if take > 0 { 1 } else { 0 };
2443 if take > 0 && next > avail {
2444 break;
2445 }
2446 used = next;
2447 take += 1;
2448 }
2449 let shown: Vec<usize> = cols.iter().skip(col).take(take).cloned().collect();
2450 let constraints: Vec<Constraint> = widths
2451 .iter()
2452 .skip(col)
2453 .take(take)
2454 .map(|&w| Constraint::Length(w))
2455 .collect();
2456 (shown, constraints)
2457}
2458
2459fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2460 let pv = app.preview.as_ref().unwrap();
2461 let acc = p.catalog;
2462 let mut title_spans: Vec<Span<'static>> = vec![
2463 Span::styled(" ◫ ", Style::default().fg(acc)),
2464 Span::styled(
2465 format!("{} · preview ", pv.name),
2466 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2467 ),
2468 Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
2469 ];
2470 let make_block = |spans: Vec<Span<'static>>| {
2471 Block::default()
2472 .title(Line::from(spans))
2473 .borders(Borders::ALL)
2474 .border_type(BorderType::Rounded)
2475 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2476 .padding(Padding::horizontal(1))
2477 };
2478
2479 match &pv.data {
2480 None => {
2481 let par = Paragraph::new(format!(
2482 "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
2483 app.spinner()
2484 ))
2485 .style(Style::default().fg(p.warn))
2486 .block(make_block(title_spans));
2487 f.render_widget(par, area);
2488 }
2489 Some(Err(e)) => {
2490 let text = format!(
2491 "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
2492 pv.warehouse,
2493 pv.warehouse_id,
2494 app.profile.as_deref().unwrap_or("default"),
2495 app.host.as_deref().unwrap_or("unknown"),
2496 );
2497 let par = Paragraph::new(text)
2498 .style(Style::default().fg(p.err))
2499 .wrap(Wrap { trim: false })
2500 .block(make_block(title_spans));
2501 f.render_widget(par, area);
2502 }
2503 Some(Ok(data)) => {
2504 title_spans.push(Span::styled(
2505 format!("· {} rows ", data.rows.len()),
2506 Style::default().fg(p.dim),
2507 ));
2508 let cols = pv.visible_cols();
2509 if !pv.filter.is_empty() {
2510 title_spans.push(Span::styled(
2511 format!("· cols /{} ", pv.filter),
2512 Style::default().fg(p.warn),
2513 ));
2514 }
2515 if cols.is_empty() {
2516 let par = Paragraph::new(format!(
2517 "no columns match /{} — backspace to widen, esc to clear",
2518 pv.filter
2519 ))
2520 .style(Style::default().fg(p.warn))
2521 .block(make_block(title_spans));
2522 f.render_widget(par, area);
2523 return;
2524 }
2525 if pv.record {
2526 let row_n = pv.scroll.min(data.rows.len().saturating_sub(1));
2529 title_spans.push(Span::styled(
2530 format!("· row {}/{} ", row_n + 1, data.rows.len()),
2531 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2532 ));
2533 let name_w = cols
2534 .iter()
2535 .map(|&i| data.headers[i].chars().count())
2536 .max()
2537 .unwrap_or(0)
2538 .min(32);
2539 let empty: Vec<String> = Vec::new();
2540 let row = data.rows.get(row_n).unwrap_or(&empty);
2541 let lines: Vec<Line> = cols
2542 .iter()
2543 .map(|&i| {
2544 let value = row.get(i).map(String::as_str).unwrap_or("");
2545 let vstyle = if value == "␀" {
2546 Style::default().fg(p.dim)
2547 } else {
2548 Style::default().fg(p.text)
2549 };
2550 Line::from(vec![
2551 Span::styled(
2552 format!("{:<name_w$} ", data.headers[i]),
2553 Style::default().fg(acc),
2554 ),
2555 Span::styled(value.to_string(), vstyle),
2556 ])
2557 })
2558 .collect();
2559 let total = lines.len();
2560 let par = Paragraph::new(lines)
2561 .scroll((pv.rscroll, 0))
2562 .block(make_block(title_spans));
2563 f.render_widget(par, area);
2564 scrollbar(
2565 f,
2566 area,
2567 total,
2568 area.height.saturating_sub(2) as usize,
2569 pv.rscroll as usize,
2570 p,
2571 );
2572 return;
2573 }
2574 let avail = area.width.saturating_sub(4) as usize; let (shown, constraints) = grid_slice(data, &cols, pv.col, avail);
2576 if shown.len() < cols.len() {
2577 let first = pv.col.min(cols.len() - 1) + 1;
2578 title_spans.push(Span::styled(
2579 format!(
2580 "· cols {}–{} of {} ",
2581 first,
2582 first + shown.len() - 1,
2583 cols.len()
2584 ),
2585 Style::default().fg(p.dim),
2586 ));
2587 }
2588 let header = Row::new(
2589 shown
2590 .iter()
2591 .map(|&i| {
2592 Cell::from(data.headers[i].clone())
2593 .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2594 })
2595 .collect::<Vec<_>>(),
2596 );
2597 let rows: Vec<Row> = data
2598 .rows
2599 .iter()
2600 .skip(pv.scroll)
2601 .map(|r| {
2602 Row::new(
2603 shown
2604 .iter()
2605 .map(|&i| {
2606 Cell::from(r.get(i).cloned().unwrap_or_default())
2607 .style(Style::default().fg(p.text))
2608 })
2609 .collect::<Vec<_>>(),
2610 )
2611 })
2612 .collect();
2613 let table = Table::new(rows, constraints)
2614 .header(header)
2615 .column_spacing(1)
2616 .block(make_block(title_spans));
2617 f.render_widget(table, area);
2618 scrollbar(
2620 f,
2621 area,
2622 data.rows.len(),
2623 area.height.saturating_sub(3) as usize,
2624 pv.scroll,
2625 p,
2626 );
2627 }
2628 }
2629}
2630
2631fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2632 let d = app.detail.as_ref().unwrap();
2633 let acc = accent(d.panel, p);
2634 let title = Line::from(vec![
2635 Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
2636 Span::styled(
2637 format!("{} · {} ", d.panel.title(), d.name),
2638 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2639 ),
2640 ]);
2641 let block = Block::default()
2642 .title(title)
2643 .borders(Borders::ALL)
2644 .border_type(BorderType::Rounded)
2645 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2646 .padding(Padding::horizontal(1));
2647
2648 let Some(data) = &d.data else {
2649 let par = Paragraph::new(format!("{} Loading…", app.spinner()))
2650 .style(Style::default().fg(p.warn))
2651 .block(block);
2652 f.render_widget(par, area);
2653 return;
2654 };
2655
2656 if d.show_raw || data.summary.is_empty() {
2658 let par = Paragraph::new(data.raw.as_str())
2659 .style(Style::default().fg(p.text))
2660 .wrap(Wrap { trim: false })
2661 .scroll((d.scroll, 0))
2662 .block(block);
2663 f.render_widget(par, area);
2664 let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
2665 scrollbar(
2666 f,
2667 area,
2668 total,
2669 area.height.saturating_sub(2) as usize,
2670 d.scroll as usize,
2671 p,
2672 );
2673 return;
2674 }
2675
2676 let mut lines: Vec<Line> = data
2677 .summary
2678 .iter()
2679 .map(|(k, v)| {
2680 Line::from(vec![
2681 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
2682 Span::styled(v.as_str(), Style::default().fg(p.text)),
2683 ])
2684 })
2685 .collect();
2686 if !data.activity.is_empty() {
2687 lines.push(Line::default());
2688 lines.push(Line::from(Span::styled(
2689 d.section,
2690 Style::default().fg(acc).add_modifier(Modifier::BOLD),
2691 )));
2692 for (status, text) in &data.activity {
2693 let treeish = text.is_empty() || text.starts_with(['├', '└', '│', '▲', '▼', ' ']);
2695 if treeish {
2696 lines.push(Line::from(Span::styled(
2697 text.as_str(),
2698 Style::default().fg(status_color(status, p)),
2699 )));
2700 } else {
2701 lines.push(Line::from(vec![
2702 Span::styled("● ", Style::default().fg(status_color(status, p))),
2703 Span::styled(text.as_str(), Style::default().fg(p.text)),
2704 ]));
2705 }
2706 }
2707 }
2708 let total = lines.len();
2709 let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
2710 f.render_widget(par, area);
2711 scrollbar(
2712 f,
2713 area,
2714 total,
2715 area.height.saturating_sub(2) as usize,
2716 d.scroll as usize,
2717 p,
2718 );
2719}
2720
2721fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2722 let rv = app.run_view.as_ref().unwrap();
2723 let acc = accent(rv.panel, p);
2724 let noun = if rv.panel == Panel::Jobs {
2725 "run"
2726 } else {
2727 "update"
2728 };
2729 let mut title_spans = vec![
2730 Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
2731 Span::styled(
2732 format!("{} · {noun} ", rv.owner_name),
2733 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2734 ),
2735 ];
2736 if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
2737 title_spans.push(Span::styled(
2738 format!("{run_id} "),
2739 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2740 ));
2741 title_spans.push(Span::styled(
2742 format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
2743 Style::default().fg(p.dim),
2744 ));
2745 }
2746 if rv.live {
2747 title_spans.push(Span::styled(
2748 format!("{} live ", app.spinner()),
2749 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2750 ));
2751 }
2752 if rv.show_output {
2753 title_spans.push(Span::styled(
2754 if rv.live {
2755 "· output (tailing) "
2756 } else {
2757 "· output "
2758 },
2759 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2760 ));
2761 }
2762 if rv.show_timeline {
2763 title_spans.push(Span::styled(
2764 "· timeline ",
2765 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2766 ));
2767 }
2768 if rv.show_dag {
2769 title_spans.push(Span::styled(
2770 "· dag ",
2771 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2772 ));
2773 }
2774 if rv.show_grid {
2775 title_spans.push(Span::styled(
2776 "· history grid ",
2777 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2778 ));
2779 }
2780 let block = Block::default()
2781 .title(Line::from(title_spans))
2782 .borders(Borders::ALL)
2783 .border_type(BorderType::Rounded)
2784 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2785 .padding(Padding::horizontal(1));
2786
2787 if rv.show_output {
2789 let par = match &rv.output {
2790 Some(text) => {
2791 let lines: Vec<Line> = text.lines().map(|l| output_line(l, p)).collect();
2792 Paragraph::new(lines)
2793 .wrap(Wrap { trim: false })
2794 .scroll((rv.scroll, 0))
2795 .block(block)
2796 }
2797 None => Paragraph::new(format!(
2798 "{} fetching task output — one call per task…",
2799 app.spinner()
2800 ))
2801 .style(Style::default().fg(p.warn))
2802 .block(block),
2803 };
2804 f.render_widget(par, area);
2805 if let Some(text) = &rv.output {
2806 let total = wrapped_height(text, area.width.saturating_sub(4) as usize);
2807 scrollbar(
2808 f,
2809 area,
2810 total,
2811 area.height.saturating_sub(2) as usize,
2812 rv.scroll as usize,
2813 p,
2814 );
2815 }
2816 return;
2817 }
2818
2819 if rv.show_grid {
2820 draw_run_grid(f, area, rv, app, block, p);
2821 return;
2822 }
2823
2824 let Some(data) = &rv.data else {
2825 let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
2826 .style(Style::default().fg(p.warn))
2827 .block(block);
2828 f.render_widget(par, area);
2829 return;
2830 };
2831
2832 if rv.show_timeline {
2833 draw_run_timeline(f, area, rv.scroll, data, block, p);
2834 return;
2835 }
2836
2837 if rv.show_dag {
2838 draw_run_dag(f, area, rv.scroll, data, block, p);
2839 return;
2840 }
2841
2842 if rv.show_raw || data.summary.is_empty() {
2843 let par = Paragraph::new(data.raw.as_str())
2844 .style(Style::default().fg(p.text))
2845 .wrap(Wrap { trim: false })
2846 .scroll((rv.scroll, 0))
2847 .block(block);
2848 f.render_widget(par, area);
2849 let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
2850 scrollbar(
2851 f,
2852 area,
2853 total,
2854 area.height.saturating_sub(2) as usize,
2855 rv.scroll as usize,
2856 p,
2857 );
2858 return;
2859 }
2860
2861 let mut lines: Vec<Line> = data
2862 .summary
2863 .iter()
2864 .map(|(k, v)| {
2865 Line::from(vec![
2866 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
2867 Span::styled(v.as_str(), Style::default().fg(p.text)),
2868 ])
2869 })
2870 .collect();
2871 if !data.activity.is_empty() {
2872 lines.push(Line::default());
2873 lines.push(Line::from(Span::styled(
2874 if rv.panel == Panel::Jobs {
2875 "Tasks"
2876 } else {
2877 "Event log"
2878 },
2879 Style::default().fg(acc).add_modifier(Modifier::BOLD),
2880 )));
2881 for (status, text) in &data.activity {
2882 if text.starts_with(" ↳") {
2884 lines.push(Line::from(Span::styled(
2885 text.as_str(),
2886 Style::default().fg(p.err),
2887 )));
2888 } else {
2889 lines.push(Line::from(vec![
2890 Span::styled("● ", Style::default().fg(status_color(status, p))),
2891 Span::styled(text.as_str(), Style::default().fg(p.text)),
2892 ]));
2893 }
2894 }
2895 }
2896 let total = lines.len();
2897 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
2898 f.render_widget(par, area);
2899 scrollbar(
2900 f,
2901 area,
2902 total,
2903 area.height.saturating_sub(2) as usize,
2904 rv.scroll as usize,
2905 p,
2906 );
2907}
2908
2909fn draw_run_timeline(
2912 f: &mut Frame,
2913 area: Rect,
2914 scroll: u16,
2915 data: &DetailData,
2916 block: Block,
2917 p: &Palette,
2918) {
2919 let tasks = crate::fetchers::runs::timeline(&data.raw);
2920 let now = std::time::SystemTime::now()
2921 .duration_since(std::time::UNIX_EPOCH)
2922 .map(|d| d.as_millis() as u64)
2923 .unwrap_or(0);
2924 let t0 = tasks.iter().filter(|t| t.start > 0).map(|t| t.start).min();
2925 let Some(t0) = t0 else {
2926 let par = Paragraph::new(
2927 "no per-task timing recorded — legacy single-task runs carry none (J shows the raw JSON)",
2928 )
2929 .style(Style::default().fg(p.dim))
2930 .wrap(Wrap { trim: false })
2931 .block(block);
2932 f.render_widget(par, area);
2933 return;
2934 };
2935 let t1 = tasks
2936 .iter()
2937 .filter(|t| t.start > 0)
2938 .map(|t| t.end.unwrap_or(now).max(t.start))
2939 .max()
2940 .unwrap_or(t0);
2941 let span = (t1 - t0).max(1);
2942
2943 let name_w = tasks
2944 .iter()
2945 .map(|t| t.key.chars().count())
2946 .max()
2947 .unwrap_or(4)
2948 .clamp(4, 24);
2949 let inner_w = area.width.saturating_sub(4) as usize;
2950 let bar_w = inner_w.saturating_sub(name_w + 2 + 9).max(10);
2952
2953 let mut lines: Vec<Line> = vec![
2954 Line::from(Span::styled(
2955 format!(
2956 "◷ {} task{} · span {}",
2957 tasks.len(),
2958 if tasks.len() == 1 { "" } else { "s" },
2959 crate::shape::fmt_duration_ms(span)
2960 ),
2961 Style::default().fg(p.dim),
2962 )),
2963 Line::default(),
2964 ];
2965 for t in &tasks {
2966 let mut key: String = t.key.chars().take(name_w).collect();
2967 if key.len() < t.key.len() {
2968 key.pop();
2969 key.push('…');
2970 }
2971 let name = Span::styled(format!("{key:<name_w$} "), Style::default().fg(p.text));
2972 if t.start == 0 {
2973 lines.push(Line::from(vec![
2974 name,
2975 Span::styled(
2976 format!("– {}", t.status.label().to_lowercase()),
2977 Style::default().fg(p.dim),
2978 ),
2979 ]));
2980 continue;
2981 }
2982 let end = t.end.unwrap_or(now).max(t.start);
2983 let off = ((t.start - t0) as f64 / span as f64 * bar_w as f64) as usize;
2984 let len = (((end - t.start) as f64 / span as f64) * bar_w as f64).round() as usize;
2985 let len = len.clamp(1, bar_w.saturating_sub(off).max(1));
2986 let dur = format!(
2987 " {}{}",
2988 crate::shape::fmt_duration_ms(end - t.start),
2989 if t.end.is_none() { "…" } else { "" }
2990 );
2991 lines.push(Line::from(vec![
2992 name,
2993 Span::styled(" ".repeat(off), Style::default()),
2994 Span::styled(
2995 "█".repeat(len),
2996 Style::default().fg(status_color(&t.status, p)),
2997 ),
2998 Span::styled(dur, Style::default().fg(p.dim)),
2999 ]));
3000 }
3001 let total = lines.len();
3002 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
3003 f.render_widget(par, area);
3004 scrollbar(
3005 f,
3006 area,
3007 total,
3008 area.height.saturating_sub(2) as usize,
3009 scroll as usize,
3010 p,
3011 );
3012}
3013
3014fn draw_run_dag(
3017 f: &mut Frame,
3018 area: Rect,
3019 scroll: u16,
3020 data: &DetailData,
3021 block: Block,
3022 p: &Palette,
3023) {
3024 let rows = crate::fetchers::runs::dag(&data.raw);
3025 if rows.is_empty() {
3026 let par = Paragraph::new(
3027 "no task graph recorded — legacy single-task runs carry none (J shows the raw JSON)",
3028 )
3029 .style(Style::default().fg(p.dim))
3030 .wrap(Wrap { trim: false })
3031 .block(block);
3032 f.render_widget(par, area);
3033 return;
3034 }
3035 let mut lines: Vec<Line> = vec![
3036 Line::from(Span::styled(
3037 format!(
3038 "⧉ {} task{} · order of execution, top to bottom",
3039 rows.len(),
3040 if rows.len() == 1 { "" } else { "s" },
3041 ),
3042 Style::default().fg(p.dim),
3043 )),
3044 Line::default(),
3045 ];
3046 for r in &rows {
3047 let mut spans = vec![
3048 Span::styled(r.prefix.clone(), Style::default().fg(p.dim)),
3049 Span::styled("● ", Style::default().fg(status_color(&r.status, p))),
3050 Span::styled(r.key.clone(), Style::default().fg(p.text)),
3051 Span::styled(
3052 format!(" {}", r.status.label().to_lowercase()),
3053 Style::default().fg(status_color(&r.status, p)),
3054 ),
3055 ];
3056 if let Some(d) = r.duration {
3057 spans.push(Span::styled(
3058 format!(" · {}", crate::shape::fmt_duration_ms(d)),
3059 Style::default().fg(p.dim),
3060 ));
3061 }
3062 if !r.also_after.is_empty() {
3063 spans.push(Span::styled(
3064 format!(" (also after {})", r.also_after.join(", ")),
3065 Style::default().fg(p.dim),
3066 ));
3067 }
3068 lines.push(Line::from(spans));
3069 }
3070 let total = lines.len();
3071 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
3072 f.render_widget(par, area);
3073 scrollbar(
3074 f,
3075 area,
3076 total,
3077 area.height.saturating_sub(2) as usize,
3078 scroll as usize,
3079 p,
3080 );
3081}
3082
3083fn spark(vals: &[u64]) -> String {
3086 const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
3087 let (min, max) = vals
3088 .iter()
3089 .fold((u64::MAX, 0), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
3090 vals.iter()
3091 .map(|v| {
3092 if max == min {
3093 '▄'
3094 } else {
3095 BARS[((v - min) * 7 / (max - min)) as usize]
3096 }
3097 })
3098 .collect()
3099}
3100
3101fn draw_run_grid(
3107 f: &mut Frame,
3108 area: Rect,
3109 rv: &crate::app::RunView,
3110 app: &App,
3111 block: Block,
3112 p: &Palette,
3113) {
3114 let g = match &rv.grid {
3115 None => {
3116 let par = Paragraph::new(format!("{} loading run history…", app.spinner()))
3117 .style(Style::default().fg(p.warn))
3118 .block(block);
3119 f.render_widget(par, area);
3120 return;
3121 }
3122 Some(Err(e)) => {
3123 let par = Paragraph::new(format!("✗ {e}"))
3124 .style(Style::default().fg(p.err))
3125 .wrap(Wrap { trim: false })
3126 .block(block);
3127 f.render_widget(par, area);
3128 return;
3129 }
3130 Some(Ok(g)) => g,
3131 };
3132 if g.tasks.is_empty() {
3133 let par = Paragraph::new(
3134 "no per-task history recorded — legacy single-task runs carry none (J shows the raw JSON)",
3135 )
3136 .style(Style::default().fg(p.dim))
3137 .wrap(Wrap { trim: false })
3138 .block(block);
3139 f.render_widget(par, area);
3140 return;
3141 }
3142
3143 let name_w = g
3144 .tasks
3145 .iter()
3146 .map(|t| t.key.chars().count())
3147 .max()
3148 .unwrap_or(4)
3149 .clamp(4, 24);
3150 let inner_w = area.width.saturating_sub(4) as usize;
3151 let trend_w = 28;
3153 let cols_fit = (inner_w.saturating_sub(name_w + 2 + trend_w) / 2).max(1);
3155 let skip = g.runs.len().saturating_sub(cols_fit);
3156 let cols = &g.runs[skip..];
3157
3158 let mut lines: Vec<Line> = vec![
3159 Line::from(Span::styled(
3160 format!(
3161 "⊞ {} task{} × last {} run{} · oldest → newest",
3162 g.tasks.len(),
3163 if g.tasks.len() == 1 { "" } else { "s" },
3164 cols.len(),
3165 if cols.len() == 1 { "" } else { "s" },
3166 ),
3167 Style::default().fg(p.dim),
3168 )),
3169 Line::default(),
3170 ];
3171
3172 let sel = rv.runs.get(rv.idx).map(|(id, _, _)| id.as_str());
3174 if let Some(ci) = sel.and_then(|id| cols.iter().position(|r| r.run_id == id)) {
3175 let acc = accent(Panel::Jobs, p);
3176 lines.push(Line::from(vec![
3177 Span::raw(" ".repeat(name_w + 2 + 2 * ci)),
3178 Span::styled("▾", Style::default().fg(acc).add_modifier(Modifier::BOLD)),
3179 ]));
3180 }
3181
3182 let mut run_row = vec![Span::styled(
3183 format!("{:<name_w$} ", "run"),
3184 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
3185 )];
3186 for r in cols {
3187 run_row.push(Span::styled(
3188 format!("{} ", history_glyph(&r.status)),
3189 Style::default().fg(status_color(&r.status, p)),
3190 ));
3191 }
3192 if let Some(newest) = cols.last() {
3193 run_row.push(Span::styled(
3194 format!(" newest {}", newest.age),
3195 Style::default().fg(p.dim),
3196 ));
3197 }
3198 lines.push(Line::from(run_row));
3199 lines.push(Line::default());
3200
3201 for t in &g.tasks {
3202 let mut key: String = t.key.chars().take(name_w).collect();
3203 if key.chars().count() < t.key.chars().count() {
3204 key.pop();
3205 key.push('…');
3206 }
3207 let mut spans = vec![Span::styled(
3208 format!("{key:<name_w$} "),
3209 Style::default().fg(p.text),
3210 )];
3211 for cell in &t.cells[skip..] {
3212 spans.push(match cell {
3213 Some(s) => Span::styled(
3214 format!("{} ", history_glyph(s)),
3215 Style::default().fg(status_color(s, p)),
3216 ),
3217 None => Span::styled("· ", Style::default().fg(p.dim)),
3218 });
3219 }
3220 let ds = t.success_durations();
3221 if let Some(latest) = ds.last() {
3222 let window = &ds[ds.len().saturating_sub(10)..];
3223 spans.push(Span::styled(
3224 format!(" {}", spark(window)),
3225 Style::default().fg(p.dim),
3226 ));
3227 spans.push(Span::styled(
3228 format!(" {}", crate::shape::fmt_duration_ms(*latest)),
3229 Style::default().fg(p.text),
3230 ));
3231 }
3232 if let Some((latest, median)) = t.slowdown() {
3233 let ratio = latest as f64 / median as f64;
3234 let color = if ratio >= 2.0 { p.err } else { p.warn };
3235 spans.push(Span::styled(
3236 format!(" ▲{ratio:.1}×"),
3237 Style::default().fg(color).add_modifier(Modifier::BOLD),
3238 ));
3239 }
3240 lines.push(Line::from(spans));
3241 }
3242
3243 let total = lines.len();
3244 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
3245 f.render_widget(par, area);
3246 scrollbar(
3247 f,
3248 area,
3249 total,
3250 area.height.saturating_sub(2) as usize,
3251 rv.scroll as usize,
3252 p,
3253 );
3254}
3255
3256fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
3257 let block = Block::default()
3258 .borders(Borders::ALL)
3259 .border_type(BorderType::Rounded)
3260 .border_style(Style::default().fg(p.border));
3261 let inner = block.inner(area);
3262 f.render_widget(block, area);
3263
3264 let (from, to) = (p.grad_from, p.grad_to);
3268 let grad = |t: f32| {
3269 Color::Rgb(
3270 (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
3271 (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
3272 (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
3273 )
3274 };
3275 let mut left = vec![
3276 Span::styled(
3277 " ◢",
3278 Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
3279 ),
3280 Span::styled(
3281 "◤ ",
3282 Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
3283 ),
3284 ];
3285 let word: Vec<char> = "DATABRICKS".chars().collect();
3286 let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
3287 for (i, ch) in word.iter().enumerate() {
3288 let t = i as f32 / (word.len() - 1) as f32;
3289 let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
3290 Style::default().fg(p.text).add_modifier(Modifier::BOLD)
3291 } else {
3292 Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
3293 };
3294 left.push(Span::styled(ch.to_string(), style));
3295 }
3296 left.push(Span::raw(" "));
3297 for ch in "LAKEHOUSE".chars() {
3298 left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
3299 }
3300 left.push(Span::styled(
3301 format!("· v{}", env!("CARGO_PKG_VERSION")),
3302 Style::default().fg(p.dim),
3303 ));
3304 if let Some(profile) = &app.profile {
3305 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
3306 left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
3307 left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
3308 }
3309 if let Some(Shape::Badge(b)) = &app.user_badge {
3310 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
3311 left.push(Span::styled(
3312 format!("{} {}", b.label, b.value),
3313 Style::default().fg(p.key),
3314 ));
3315 }
3316 if app.zoomed {
3317 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
3318 left.push(Span::styled(
3319 format!("⛶ {}", app.focus.title()),
3320 Style::default()
3321 .fg(accent(app.focus, p))
3322 .add_modifier(Modifier::BOLD),
3323 ));
3324 }
3325
3326 let (ok, pending, failed, idle) = app.status_counts();
3328 for (count, glyph, color) in [
3329 (ok, "●", p.ok),
3330 (pending, "◐", p.warn),
3331 (failed, "✗", p.err),
3332 (idle, "○", p.dim),
3333 ] {
3334 if count > 0 {
3335 left.push(Span::styled(" ", Style::default()));
3336 left.push(Span::styled(
3337 format!("{glyph} {count}"),
3338 Style::default().fg(color),
3339 ));
3340 }
3341 }
3342 if !app.watched.is_empty() {
3344 left.push(Span::styled(" ", Style::default()));
3345 left.push(Span::styled(
3346 format!("👁 {}", app.watched.len()),
3347 Style::default().fg(p.warn),
3348 ));
3349 }
3350 f.render_widget(Paragraph::new(Line::from(left)), inner);
3351
3352 let right = if app.loading {
3353 Line::from(Span::styled(
3354 format!("{} refreshing ", app.spinner()),
3355 Style::default().fg(p.warn),
3356 ))
3357 } else {
3358 Line::from(Span::styled(
3359 format!("updated {}s ago ", app.last_refresh_age().as_secs()),
3360 Style::default().fg(p.dim),
3361 ))
3362 };
3363 f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
3364}
3365
3366fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
3367 let key =
3368 |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
3369 let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
3370
3371 if let Some(confirm) = &app.confirm {
3372 let mut spans = vec![
3373 Span::styled(
3374 format!(" ⚠ {} ", confirm.message),
3375 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3376 ),
3377 key("y"),
3378 dim(" confirm · "),
3379 ];
3380 if confirm.params.is_some() {
3381 spans.push(key("p"));
3382 spans.push(dim(" edit parameters · "));
3383 }
3384 spans.push(dim("any other key cancels"));
3385 f.render_widget(Paragraph::new(Line::from(spans)), area);
3386 return;
3387 }
3388
3389 if app.filter_entry {
3390 let line = Line::from(vec![
3391 Span::styled(
3392 format!(" /{}", app.active_filter()),
3393 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3394 ),
3395 Span::styled("▏", Style::default().fg(p.warn)),
3396 dim(" type to filter "),
3397 key("enter"),
3398 dim(" apply "),
3399 key("esc"),
3400 dim(" clear"),
3401 ]);
3402 f.render_widget(Paragraph::new(line), area);
3403 return;
3404 }
3405
3406 if let Some(pv) = &app.preview {
3407 if pv.filter_entry {
3408 let line = Line::from(vec![
3409 Span::styled(
3410 format!(" cols /{}", pv.filter),
3411 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3412 ),
3413 Span::styled("▏", Style::default().fg(p.warn)),
3414 dim(" type to filter columns "),
3415 key("enter"),
3416 dim(" apply "),
3417 key("esc"),
3418 dim(" clear"),
3419 ]);
3420 f.render_widget(Paragraph::new(line), area);
3421 return;
3422 }
3423 }
3424
3425 let mut spans = if app.param_form.is_some() {
3426 vec![
3427 dim(" type key=value pairs "),
3428 key("enter"),
3429 dim(" run "),
3430 key("esc"),
3431 dim(" cancel"),
3432 ]
3433 } else if app.secret_form.is_some() {
3434 vec![
3435 dim(" type "),
3436 key("enter"),
3437 dim(" next / save "),
3438 key("esc"),
3439 dim(" cancel"),
3440 ]
3441 } else if app.help {
3442 vec![
3443 dim(" "),
3444 key("j"),
3445 dim("/"),
3446 key("k"),
3447 dim(" scroll any other key closes"),
3448 ]
3449 } else if app.pane_cfg.is_some() {
3450 vec![
3451 dim(" "),
3452 key("j"),
3453 dim("/"),
3454 key("k"),
3455 dim(" select "),
3456 key("space"),
3457 dim(" show/hide "),
3458 key("J"),
3459 dim("/"),
3460 key("K"),
3461 dim(" move "),
3462 key("esc"),
3463 dim(" done"),
3464 ]
3465 } else if app.jump.is_some() {
3466 vec![
3467 dim(" type to search everything "),
3468 key("\u{2191}"),
3469 dim("/"),
3470 key("\u{2193}"),
3471 dim(" select "),
3472 key("enter"),
3473 dim(" jump "),
3474 key("esc"),
3475 dim(" close"),
3476 ]
3477 } else if app.problems.is_some() {
3478 vec![
3479 dim(" "),
3480 key("j"),
3481 dim("/"),
3482 key("k"),
3483 dim(" select "),
3484 key("enter"),
3485 dim(" jump to item / switch workspace "),
3486 key("esc"),
3487 dim(" close"),
3488 ]
3489 } else if app.upcoming.is_some() {
3490 vec![
3491 dim(" "),
3492 key("j"),
3493 dim("/"),
3494 key("k"),
3495 dim(" select "),
3496 key("enter"),
3497 dim(" jump to job "),
3498 key("esc"),
3499 dim(" close"),
3500 ]
3501 } else if app.cost.is_some() || app.item_cost.is_some() {
3502 vec![
3503 dim(" "),
3504 key("esc"),
3505 dim(" back "),
3506 key("q"),
3507 dim(" quit"),
3508 ]
3509 } else if app.job_health.is_some() {
3510 vec![
3511 dim(" "),
3512 key("j"),
3513 dim("/"),
3514 key("k"),
3515 dim(" scroll "),
3516 key("esc"),
3517 dim(" back "),
3518 key("q"),
3519 dim(" quit"),
3520 ]
3521 } else if app.wh_picker.is_some() {
3522 vec![
3523 dim(" "),
3524 key("j"),
3525 dim("/"),
3526 key("k"),
3527 dim(" select "),
3528 key("enter"),
3529 dim(" run preview "),
3530 key("esc"),
3531 dim(" cancel"),
3532 ]
3533 } else if app.sql.is_some() {
3534 if app.hist_search.is_some() {
3535 vec![
3536 dim(" type to search history "),
3537 key("^r"),
3538 dim(" older match "),
3539 key("enter"),
3540 dim(" accept "),
3541 key("esc"),
3542 dim(" cancel"),
3543 ]
3544 } else {
3545 let record = app.sql.as_ref().is_some_and(|c| c.record);
3546 let transposable = app
3549 .sql
3550 .as_ref()
3551 .is_some_and(|c| matches!(&c.data, Some(Ok(t)) if !t.rows.is_empty()));
3552 let mut spans = vec![
3553 dim(" "),
3554 key("enter"),
3555 dim(" run "),
3556 key("tab"),
3557 dim(" complete "),
3558 key("↑"),
3559 dim("/"),
3560 key("↓"),
3561 dim(if record { " fields " } else { " history " }),
3565 key("^r"),
3566 dim(" search "),
3567 key("^x"),
3568 dim(" editor "),
3569 key("pgup"),
3570 dim("/"),
3571 key("pgdn"),
3572 dim(if record { " page " } else { " scroll " }),
3574 key("⇧←→"),
3575 dim(if record { " rows " } else { " cols " }),
3576 ];
3577 if transposable {
3578 spans.push(key("f2"));
3584 spans.push(dim(if record { " grid " } else { " record " }));
3585 }
3586 spans.extend([
3587 key("^s"),
3588 dim(" export "),
3589 key("esc"),
3590 dim(if app.sql.as_ref().is_some_and(|c| c.running) {
3591 " cancel query"
3592 } else if record {
3593 " back to grid"
3594 } else {
3595 " close"
3596 }),
3597 ]);
3598 spans
3599 }
3600 } else if let Some(pv) = &app.preview {
3601 if pv.record {
3602 vec![
3603 dim(" "),
3604 key("j"),
3605 dim("/"),
3606 key("k"),
3607 dim(" fields "),
3608 key("h"),
3609 dim("/"),
3610 key("l"),
3611 dim(" prev/next row "),
3612 key("v"),
3613 dim(" grid "),
3614 key("/"),
3615 dim(" filter cols "),
3616 key("esc"),
3617 dim(" back"),
3618 ]
3619 } else {
3620 vec![
3621 dim(" "),
3622 key("esc"),
3623 dim(" back "),
3624 key("j"),
3625 dim("/"),
3626 key("k"),
3627 dim(" rows "),
3628 key("←"),
3629 dim("/"),
3630 key("→"),
3631 dim(" columns "),
3632 key("v"),
3633 dim(" row view "),
3634 key("/"),
3635 dim(" filter cols "),
3636 key("e"),
3637 dim(" export csv "),
3638 key("q"),
3639 dim(" quit"),
3640 ]
3641 }
3642 } else if app.picker.is_some() {
3643 vec![
3644 dim(" "),
3645 key("j"),
3646 dim("/"),
3647 key("k"),
3648 dim(" select "),
3649 key("enter"),
3650 dim(" switch workspace "),
3651 key("esc"),
3652 dim(" cancel"),
3653 ]
3654 } else if let Some(rv) = &app.run_view {
3655 if rv.show_output {
3656 vec![
3657 dim(" "),
3658 key("esc"),
3659 dim(" back to run "),
3660 key("j"),
3661 dim("/"),
3662 key("k"),
3663 dim(" scroll "),
3664 key("q"),
3665 dim(" quit"),
3666 ]
3667 } else {
3668 let mut spans = vec![
3669 dim(" "),
3670 key("esc"),
3671 dim(" back "),
3672 key("h"),
3673 dim("/"),
3674 key("l"),
3675 dim(" older/newer "),
3676 ];
3677 if rv.panel == Panel::Jobs {
3678 spans.push(key("o"));
3679 spans.push(dim(" output "));
3680 spans.push(key("t"));
3681 spans.push(dim(" timeline "));
3682 spans.push(key("d"));
3683 spans.push(dim(" dag "));
3684 spans.push(key("g"));
3685 spans.push(dim(" grid "));
3686 spans.push(key("r"));
3687 spans.push(dim(" repair "));
3688 spans.push(key("W"));
3689 spans.push(dim(
3690 if app.watched.iter().any(|w| {
3691 rv.runs
3692 .get(rv.idx)
3693 .is_some_and(|(id, _, _)| *id == w.run_id)
3694 }) {
3695 " unwatch "
3696 } else {
3697 " watch "
3698 },
3699 ));
3700 }
3701 spans.extend([
3702 key("s"),
3703 dim(" cancel "),
3704 key("j"),
3705 dim("/"),
3706 key("k"),
3707 dim(" scroll "),
3708 key("J"),
3709 dim(" raw "),
3710 key("q"),
3711 dim(" quit"),
3712 ]);
3713 spans
3714 }
3715 } else if app.detail.is_some() {
3716 let mut spans = vec![dim(" "), key("esc"), dim(" back ")];
3717 match app.detail.as_ref() {
3718 Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
3719 spans.push(key("enter"));
3720 spans.push(dim(" latest run "));
3721 }
3722 Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
3723 spans.push(key("enter"));
3724 spans.push(dim(" latest update "));
3725 }
3726 _ => {}
3727 }
3728 spans.extend([
3729 key("j"),
3730 dim("/"),
3731 key("k"),
3732 dim(" scroll "),
3733 key("J"),
3734 dim(" raw "),
3735 key("o"),
3736 dim(" open "),
3737 key("q"),
3738 dim(" quit"),
3739 ]);
3740 spans
3741 } else {
3742 let mut spans = vec![
3743 dim(" "),
3744 key("tab"),
3745 dim("/"),
3746 key("h"),
3747 dim("/"),
3748 key("l"),
3749 dim(" switch "),
3750 key("j"),
3751 dim("/"),
3752 key("k"),
3753 dim(" select "),
3754 key("/"),
3755 dim(if app.active_filter().is_empty() {
3756 " filter "
3757 } else {
3758 " filter · esc clears "
3759 }),
3760 ];
3761 let enter_label = match app.focus {
3763 Panel::Secrets if app.secret_scope.is_some() => None,
3764 Panel::Secrets => Some(" open scope "),
3765 Panel::Catalog if app.uc_path.len() < 2 => Some(" open "),
3766 _ => Some(" details "),
3767 };
3768 if let Some(label) = enter_label {
3769 spans.push(key("enter"));
3770 spans.push(dim(label));
3771 }
3772 match app.focus {
3774 Panel::Catalog => {
3775 if !app.uc_path.is_empty() {
3776 spans.push(key("bksp"));
3777 spans.push(dim(" up "));
3778 }
3779 if app.uc_path.len() == 2 {
3780 spans.push(key("p"));
3781 spans.push(dim(" preview "));
3782 spans.push(key("L"));
3783 spans.push(dim(" lineage "));
3784 spans.push(key(":"));
3785 spans.push(dim(" query table "));
3786 }
3787 }
3788 Panel::Dashboards => {}
3789 Panel::Secrets => {
3790 if app.secret_scope.is_some() {
3791 spans.push(key("bksp"));
3792 spans.push(dim(" up "));
3793 }
3794 spans.push(key("a"));
3795 spans.push(dim(if app.secret_scope.is_some() {
3796 " add secret "
3797 } else {
3798 " new scope "
3799 }));
3800 spans.push(key("x"));
3801 spans.push(dim(" delete "));
3802 }
3803 _ => {
3804 spans.push(key("s"));
3805 spans.push(dim(" action "));
3806 if app.focus == Panel::Jobs {
3807 spans.push(key("S"));
3808 spans.push(dim(" pause "));
3809 }
3810 if matches!(app.focus, Panel::Jobs | Panel::Pipelines) {
3811 spans.push(key("c"));
3812 spans.push(dim(" spend "));
3813 }
3814 }
3815 }
3816 spans.extend([
3817 key("g"),
3818 dim(" access "),
3819 key("$"),
3820 dim(" cost "),
3821 key("!"),
3822 dim(" problems "),
3823 key("u"),
3824 dim(" upcoming "),
3825 ]);
3826 if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
3828 spans.push(key(":"));
3829 spans.push(dim(" sql "));
3830 }
3831 spans.extend([
3832 key("o"),
3833 dim(" open "),
3834 key("z"),
3835 dim(if app.zoomed { " unzoom " } else { " zoom " }),
3836 key("w"),
3837 dim(" workspace "),
3838 key("t"),
3839 dim(" theme "),
3840 key("r"),
3841 dim(" refresh "),
3842 key("?"),
3843 dim(" help "),
3844 key("q"),
3845 dim(" quit"),
3846 ]);
3847 spans
3848 };
3849 if let Some((msg, _)) = &app.flash {
3851 let color = if msg.starts_with('✗') { p.err } else { p.ok };
3852 let mut prefixed = vec![
3853 Span::styled(
3854 format!(" {msg}"),
3855 Style::default().fg(color).add_modifier(Modifier::BOLD),
3856 ),
3857 dim(" · "),
3858 ];
3859 prefixed.append(&mut spans);
3860 spans = prefixed;
3861 }
3862 f.render_widget(Paragraph::new(Line::from(spans)), area);
3863}
3864
3865#[allow(clippy::too_many_arguments)]
3866fn draw_panel(
3867 f: &mut Frame,
3868 area: Rect,
3869 panel: Panel,
3870 shape: Option<&Shape>,
3871 focused: bool,
3872 selected: Option<usize>,
3873 fresh: bool,
3874 breadcrumb: &str,
3875 spinner: &str,
3876 filter: &str,
3877 entering: bool,
3878 favs: &std::collections::HashSet<String>,
3879 fav_only: bool,
3880 p: &Palette,
3881) {
3882 let accent = accent(panel, p);
3883 let dimmed = |s: Style| {
3886 if focused {
3887 s
3888 } else {
3889 s.add_modifier(Modifier::DIM)
3890 }
3891 };
3892 let (border_style, title_style) = if focused {
3893 (
3894 Style::default().fg(accent).add_modifier(Modifier::BOLD),
3895 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
3896 )
3897 } else {
3898 (Style::default().fg(p.border), Style::default().fg(p.dim))
3899 };
3900 let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
3901 Some(Shape::List(items)) => Some(
3902 items
3903 .iter()
3904 .filter(|it| {
3905 crate::shape::item_matches(it, filter)
3906 && (!fav_only || favs.contains(&crate::shape::fav_key(it)))
3907 })
3908 .collect(),
3909 ),
3910 _ => None,
3911 };
3912 let count = match (&visible, shape) {
3913 (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
3915 format!(" · {}/{}", v.len(), items.len())
3916 }
3917 (Some(v), _) => format!(" · {}", v.len()),
3918 (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
3919 _ => String::new(),
3920 };
3921 let crumb = if !breadcrumb.is_empty() {
3922 format!(" ▸ {breadcrumb}")
3923 } else {
3924 String::new()
3925 };
3926 let title_style = if fresh {
3928 Style::default()
3929 .bg(accent)
3930 .fg(Color::Black)
3931 .add_modifier(Modifier::BOLD)
3932 } else {
3933 title_style
3934 };
3935 let mut title_spans = vec![
3936 Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
3937 Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
3938 ];
3939 if entering || !filter.is_empty() {
3940 title_spans.push(Span::styled(
3941 format!("/{}{} ", filter, if entering { "▏" } else { "" }),
3942 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3943 ));
3944 }
3945 if fav_only {
3946 title_spans.push(Span::styled(
3947 "★only ",
3948 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3949 ));
3950 }
3951 let title = Line::from(title_spans);
3952 let block = Block::default()
3953 .title(title)
3954 .borders(Borders::ALL)
3955 .border_type(if focused {
3956 BorderType::Thick
3957 } else {
3958 BorderType::Rounded
3959 })
3960 .border_style(border_style)
3961 .padding(Padding::horizontal(1));
3962
3963 match shape {
3964 None => {
3965 let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
3966 .style(Style::default().fg(p.warn))
3967 .block(block);
3968 f.render_widget(par, area);
3969 }
3970 Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
3971 let empty = if !items.is_empty() {
3972 format!("∅ nothing matches /{filter}")
3973 } else {
3974 let msg = match panel {
3975 Panel::Clusters => "no compute running",
3976 Panel::Jobs => "no jobs defined",
3977 Panel::Pipelines => "no pipelines",
3978 Panel::Warehouses => "no warehouses",
3979 Panel::Dashboards => "no dashboards yet",
3980 Panel::Catalog => "nothing at this level",
3981 Panel::Secrets => "no secret scopes",
3982 };
3983 format!("∅ {msg}")
3984 };
3985 let par = Paragraph::new(empty)
3986 .style(Style::default().fg(p.dim))
3987 .block(block);
3988 f.render_widget(par, area);
3989 }
3990 Some(Shape::List(_)) => {
3991 let list_items: Vec<ListItem> = visible
3992 .unwrap_or_default()
3993 .iter()
3994 .map(|item| {
3995 let color = status_color(&item.status, p);
3996 let chip = match item.status {
3997 Status::Stopped | Status::Unknown(_) => Span::styled(
3998 format!(" {} ", item.status.label()),
3999 Style::default().fg(color).add_modifier(Modifier::DIM),
4000 ),
4001 _ => Span::styled(
4002 format!(" {} ", item.status.label()),
4003 Style::default()
4004 .bg(color)
4005 .fg(Color::Black)
4006 .add_modifier(Modifier::BOLD),
4007 ),
4008 };
4009 let star = if favs.contains(&crate::shape::fav_key(item)) {
4010 Span::styled("★ ", Style::default().fg(p.warn))
4011 } else {
4012 Span::raw(" ")
4013 };
4014 let mut spans = vec![
4015 star,
4016 Span::styled("● ", Style::default().fg(color)),
4017 Span::styled(item.name.as_str(), dimmed(Style::default().fg(p.text))),
4018 Span::raw(" "),
4019 chip,
4020 ];
4021 if !item.history.is_empty() {
4022 spans.push(Span::raw(" "));
4023 for run in &item.history {
4024 spans.push(Span::styled(
4025 history_glyph(run),
4026 Style::default().fg(status_color(run, p)),
4027 ));
4028 }
4029 }
4030 if let Some(detail) = &item.detail {
4031 let fg = if item.alert.is_some() { p.warn } else { p.dim };
4033 spans.push(Span::styled(
4034 format!(" {}", detail),
4035 dimmed(Style::default().fg(fg)),
4036 ));
4037 }
4038 ListItem::new(Line::from(spans))
4039 })
4040 .collect();
4041 let list = List::new(list_items)
4042 .block(block)
4043 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
4044 match selected {
4045 Some(sel) => {
4046 let mut state = ListState::default().with_selected(Some(sel));
4047 f.render_stateful_widget(list, area, &mut state);
4048 }
4049 None => f.render_widget(list, area),
4050 }
4051 }
4052 Some(Shape::Table(data)) => {
4053 let header_cells: Vec<Cell> = data
4054 .headers
4055 .iter()
4056 .map(|h| {
4057 Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
4058 })
4059 .collect();
4060 let header = Row::new(header_cells).style(Style::default().fg(accent));
4061 let rows: Vec<Row> = data
4062 .rows
4063 .iter()
4064 .map(|r| {
4065 Row::new(
4066 r.iter()
4067 .map(|c| Cell::from(c.as_str()).style(dimmed(Style::default())))
4068 .collect::<Vec<_>>(),
4069 )
4070 })
4071 .collect();
4072 let widths: Vec<Constraint> = data
4073 .headers
4074 .iter()
4075 .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
4076 .collect();
4077 let table = Table::new(rows, widths).header(header).block(block);
4078 f.render_widget(table, area);
4079 }
4080 Some(Shape::Badge(b)) => {
4081 let text = format!("{}: {}", b.label, b.value);
4082 let par = Paragraph::new(text)
4083 .style(dimmed(Style::default().fg(p.text)))
4084 .block(block);
4085 f.render_widget(par, area);
4086 }
4087 Some(Shape::Text(t)) => {
4088 let color = if t.starts_with('✗') { p.err } else { p.text };
4089 let par = Paragraph::new(t.as_str())
4090 .style(dimmed(Style::default().fg(color)))
4091 .wrap(Wrap { trim: false })
4092 .block(block);
4093 f.render_widget(par, area);
4094 }
4095 }
4096}
4097
4098fn scrollbar(f: &mut Frame, area: Rect, total: usize, viewport: usize, pos: usize, p: &Palette) {
4102 if viewport == 0 || total <= viewport {
4103 return;
4104 }
4105 let max = total - viewport;
4106 let mut state = ScrollbarState::new(max).position(pos.min(max));
4107 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
4108 .begin_symbol(None)
4109 .end_symbol(None)
4110 .track_symbol(Some("│"))
4111 .track_style(Style::default().fg(p.border))
4112 .thumb_symbol("┃")
4113 .thumb_style(Style::default().fg(p.text));
4114 f.render_stateful_widget(
4115 sb,
4116 area.inner(Margin {
4117 horizontal: 0,
4118 vertical: 1,
4119 }),
4120 &mut state,
4121 );
4122}
4123
4124fn wrapped_height(text: &str, width: usize) -> usize {
4126 let w = width.max(1);
4127 text.lines()
4128 .map(|l| l.chars().count().div_ceil(w).max(1))
4129 .sum()
4130}
4131
4132const SQL_EXTRA_KEYWORDS: &[&str] = &[
4135 "TABLE", "VIEW", "IF", "EXISTS", "USE", "TRUE", "FALSE", "ASC", "DESC", "INTERVAL",
4136];
4137
4138fn is_sql_keyword(word: &str) -> bool {
4139 let up = word.to_ascii_uppercase();
4140 crate::app::SQL_KEYWORDS
4141 .iter()
4142 .any(|k| k.split_whitespace().any(|w| w == up))
4143 || SQL_EXTRA_KEYWORDS.contains(&up.as_str())
4144}
4145
4146fn sql_spans(sql: &str, p: &Palette) -> Vec<Span<'static>> {
4150 let chars: Vec<char> = sql.chars().collect();
4151 let mut spans = Vec::new();
4152 let mut i = 0;
4153 let push = |spans: &mut Vec<Span<'static>>, text: String, style: Style| {
4154 if !text.is_empty() {
4155 spans.push(Span::styled(text, style));
4156 }
4157 };
4158 while i < chars.len() {
4159 let c = chars[i];
4160 if c == '\'' || c == '`' || c == '"' {
4161 let start = i;
4164 i += 1;
4165 while i < chars.len() && chars[i] != c {
4166 i += 1;
4167 }
4168 if i < chars.len() {
4169 i += 1;
4170 }
4171 let color = if c == '\'' { p.ok } else { p.catalog };
4172 push(
4173 &mut spans,
4174 chars[start..i].iter().collect(),
4175 Style::default().fg(color),
4176 );
4177 } else if c == '-' && chars.get(i + 1) == Some(&'-') {
4178 push(
4179 &mut spans,
4180 chars[i..].iter().collect(),
4181 Style::default().fg(p.dim),
4182 );
4183 break;
4184 } else if c.is_ascii_digit() {
4185 let start = i;
4186 while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '.') {
4187 i += 1;
4188 }
4189 push(
4190 &mut spans,
4191 chars[start..i].iter().collect(),
4192 Style::default().fg(p.warn),
4193 );
4194 } else if c.is_alphanumeric() || c == '_' {
4195 let start = i;
4196 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
4197 i += 1;
4198 }
4199 let word: String = chars[start..i].iter().collect();
4200 let style = if is_sql_keyword(&word) {
4201 Style::default().fg(p.key).add_modifier(Modifier::BOLD)
4202 } else {
4203 Style::default().fg(p.text)
4204 };
4205 push(&mut spans, word, style);
4206 } else {
4207 let start = i;
4208 i += 1;
4209 while i < chars.len() {
4210 let c = chars[i];
4211 if c.is_alphanumeric() || matches!(c, '_' | '\'' | '`' | '"' | '-') {
4212 break;
4213 }
4214 i += 1;
4215 }
4216 push(
4217 &mut spans,
4218 chars[start..i].iter().collect(),
4219 Style::default().fg(p.text),
4220 );
4221 }
4222 }
4223 spans
4224}
4225
4226fn output_line<'a>(line: &'a str, p: &Palette) -> Line<'a> {
4230 if let Some(rest) = line.strip_prefix("── ") {
4231 let color = if rest.contains("FAILED") {
4232 p.err
4233 } else if rest.contains("RUNNING") || rest.contains("PENDING") {
4234 p.warn
4235 } else if rest.contains("SUCCESS") {
4236 p.ok
4237 } else {
4238 p.text
4239 };
4240 return Line::from(Span::styled(
4241 line,
4242 Style::default().fg(color).add_modifier(Modifier::BOLD),
4243 ));
4244 }
4245 let trimmed = line.trim_start();
4246 let indented = line.starts_with(char::is_whitespace);
4247 let body = if line.contains("ERROR")
4248 || line.contains("FATAL")
4249 || line.contains("Exception")
4250 || trimmed.starts_with("Traceback")
4251 || trimmed.starts_with("Caused by")
4252 || (indented && (trimmed.starts_with("at ") || trimmed.starts_with("File \"")))
4253 {
4254 Style::default().fg(p.err)
4255 } else if line.contains("WARN") {
4256 Style::default().fg(p.warn)
4257 } else if line == "logs (tail):" || trimmed.starts_with("(no output") {
4258 Style::default().fg(p.dim)
4259 } else {
4260 Style::default().fg(p.text)
4261 };
4262 let ts = timestamp_prefix(line);
4263 if ts > 0 {
4264 Line::from(vec![
4265 Span::styled(&line[..ts], Style::default().fg(p.dim)),
4266 Span::styled(&line[ts..], body),
4267 ])
4268 } else {
4269 Line::from(Span::styled(line, body))
4270 }
4271}
4272
4273fn timestamp_prefix(line: &str) -> usize {
4277 let mut len = 0;
4278 let mut digits = 0;
4279 for (i, c) in line.char_indices() {
4280 if c.is_ascii_digit() {
4281 digits += 1;
4282 } else if !matches!(c, '/' | '-' | ':' | '.' | ',' | 'T' | 'Z' | ' ') {
4283 break;
4284 }
4285 len = i + c.len_utf8();
4286 if len >= 30 {
4287 break;
4288 }
4289 }
4290 if digits >= 6 && len >= 8 {
4291 len
4292 } else {
4293 0
4294 }
4295}
4296
4297fn status_color(status: &Status, p: &Palette) -> Color {
4298 match status {
4299 Status::Running | Status::Success => p.ok,
4300 Status::Stopped => p.dim,
4301 Status::Pending => p.warn,
4302 Status::Failed => p.err,
4303 Status::Unknown(_) => p.text,
4304 }
4305}
4306
4307fn history_glyph(status: &Status) -> &'static str {
4308 match status {
4309 Status::Success => "✓",
4310 Status::Failed => "✗",
4311 Status::Running => "●",
4312 Status::Pending => "◐",
4313 Status::Stopped => "○",
4314 Status::Unknown(_) => "·",
4315 }
4316}
4317
4318#[cfg(test)]
4319mod tests {
4320 use super::*;
4321
4322 fn find<'a>(spans: &'a [Span], text: &str) -> &'a Span<'a> {
4323 spans
4324 .iter()
4325 .find(|s| s.content == text)
4326 .unwrap_or_else(|| panic!("no span {text:?} in {spans:?}"))
4327 }
4328
4329 #[test]
4330 fn sql_spans_color_keywords_strings_numbers_comments() {
4331 let p = palette(ThemeMode::Dark);
4332 let spans = sql_spans("SELECT count(x) FROM t WHERE s = 'bob' LIMIT 10 -- hi", &p);
4333 assert_eq!(find(&spans, "SELECT").style.fg, Some(p.key));
4334 assert!(find(&spans, "SELECT")
4335 .style
4336 .add_modifier
4337 .contains(Modifier::BOLD));
4338 assert_eq!(find(&spans, "count").style.fg, Some(p.key));
4339 assert_eq!(find(&spans, "t").style.fg, Some(p.text));
4340 assert_eq!(find(&spans, "'bob'").style.fg, Some(p.ok));
4341 assert_eq!(find(&spans, "10").style.fg, Some(p.warn));
4342 assert_eq!(find(&spans, "-- hi").style.fg, Some(p.dim));
4343 }
4344
4345 #[test]
4346 fn sql_spans_preserve_every_char() {
4347 let p = palette(ThemeMode::Dark);
4348 for sql in [
4349 "SELECT `col a`, b - 1 FROM main.sales.orders WHERE s = 'unclosed",
4350 "-- only a comment",
4351 " \"quoted id\" <> 3.5e2 ",
4352 ] {
4353 let joined: String = sql_spans(sql, &p)
4354 .iter()
4355 .map(|s| s.content.as_ref())
4356 .collect();
4357 assert_eq!(joined, sql);
4358 }
4359 }
4360
4361 #[test]
4362 fn spark_scales_min_to_max() {
4363 assert_eq!(spark(&[1, 1, 1]), "▄▄▄");
4364 assert_eq!(spark(&[0, 7]), "▁█");
4365 assert_eq!(spark(&[100, 150, 200]), "▁▄█");
4366 assert_eq!(spark(&[]), "");
4367 }
4368
4369 fn render_at(app: &App, width: u16) -> Vec<String> {
4372 let backend = ratatui::backend::TestBackend::new(width, 30);
4373 let mut terminal = ratatui::Terminal::new(backend).unwrap();
4374 terminal.draw(|f| draw(f, app)).unwrap();
4375 let buffer = terminal.backend().buffer().clone();
4376 (0..buffer.area.height)
4377 .map(|y| {
4378 (0..buffer.area.width)
4379 .map(|x| buffer[(x, y)].symbol())
4380 .collect::<String>()
4381 .trim_end()
4382 .to_string()
4383 })
4384 .collect()
4385 }
4386
4387 fn render(app: &App) -> Vec<String> {
4388 render_at(app, 100)
4389 }
4390
4391 fn item_cost_app(data: fetchers::cost::ResourceCost) -> App {
4392 let mut app = App::new(60, ThemeMode::Dark);
4393 app.dismiss_splash();
4394 app.item_cost = Some(crate::app::ItemCostView {
4395 warehouse: "Starter Warehouse".to_string(),
4396 kind: data.kind,
4397 name: "nightly-etl".to_string(),
4398 id: "42".to_string(),
4399 data: Some(Ok(data)),
4400 });
4401 app
4402 }
4403
4404 fn window(
4405 label: &'static str,
4406 days: i64,
4407 dbus: f64,
4408 usd: f64,
4409 prior: Option<(f64, f64)>,
4410 ) -> fetchers::cost::CostWindow {
4411 fetchers::cost::CostWindow {
4412 label,
4413 days,
4414 dbus,
4415 usd,
4416 prior,
4417 }
4418 }
4419
4420 #[test]
4421 fn item_cost_view_shows_every_window_with_its_trend() {
4422 let data = fetchers::cost::ResourceCost {
4423 kind: fetchers::cost::ResourceKind::Job,
4424 windows: vec![
4425 window("last week", 7, 12.4, 9.61, Some((10.0, 8.14))),
4426 window("last month", 30, 58.2, 45.1, Some((60.0, 47.0))),
4427 window("last quarter", 90, 140.9, 109.22, Some((0.0, 0.0))),
4428 window("last year", 365, 402.3, 311.8, None),
4429 ],
4430 months: vec![
4431 ("2026-06".to_string(), 40.1, 31.02),
4432 ("2026-07".to_string(), 58.2, 45.1),
4433 ],
4434 priced: true,
4435 scoped: true,
4436 };
4437 let text = render(&item_cost_app(data)).join("\n");
4438
4439 assert!(
4440 text.contains("Spend · nightly-etl · job · this workspace"),
4441 "{text}"
4442 );
4443 for label in ["last week", "last month", "last quarter", "last year"] {
4444 assert!(text.contains(label), "missing {label} in {text}");
4445 }
4446 assert!(text.contains("$9.61"), "{text}");
4447 assert!(text.contains("▲ 18%"), "{text}");
4450 assert!(text.contains("▼ 4%"), "{text}");
4451 assert!(text.contains("BY MONTH"), "{text}");
4452 assert!(text.contains("2026-06"), "{text}");
4453 }
4454
4455 fn sql_app(record: bool) -> App {
4456 let mut app = App::new(60, ThemeMode::Dark);
4457 app.dismiss_splash();
4458 app.sql = Some(crate::app::SqlConsole {
4459 input: "SELECT * FROM main.sales.orders LIMIT 50".to_string(),
4460 cursor: 0,
4461 warehouse: "Starter Warehouse".to_string(),
4462 running: false,
4463 data: Some(Ok(TableData {
4464 headers: vec![
4465 "order_id".to_string(),
4466 "customer".to_string(),
4467 "shipped_at".to_string(),
4468 ],
4469 rows: vec![
4470 vec!["1001".to_string(), "acme".to_string(), "␀".to_string()],
4471 vec![
4472 "1002".to_string(),
4473 "globex".to_string(),
4474 "2026-08-01".to_string(),
4475 ],
4476 ],
4477 })),
4478 last_sql: "SELECT * FROM main.sales.orders LIMIT 50".to_string(),
4479 scroll: 1,
4480 col: 0,
4481 record,
4482 rscroll: 0,
4483 });
4484 app
4485 }
4486
4487 #[test]
4488 fn sql_record_view_stacks_one_row_and_marks_which() {
4489 let text = render_at(&sql_app(true), 140).join("\n");
4490
4491 assert!(text.contains("row 2/2"), "{text}");
4493 assert!(text.contains("order_id 1002"), "{text}");
4494 assert!(text.contains("customer globex"), "{text}");
4495 assert!(text.contains("shipped_at 2026-08-01"), "{text}");
4496 assert!(text.contains("fields"), "{text}");
4498 assert!(text.contains("f2 grid"), "{text}");
4499 }
4500
4501 #[test]
4502 fn sql_arrows_walk_fields_transposed_and_history_at_the_prompt() {
4503 let mut app = sql_app(true);
4505 app.sql_vertical(1);
4506 let text = render_at(&app, 140).join("\n");
4507 assert!(text.contains("customer globex"), "{text}");
4508 assert!(!text.contains("order_id 1002"), "{text}");
4509 assert!(text.contains("↑/↓ fields"), "{text}");
4510 assert!(text.contains("pgup/pgdn page"), "{text}");
4512
4513 app.sql_vertical(-1);
4515 app.sql_vertical(-1);
4516 let text = render_at(&app, 140).join("\n");
4517 assert!(text.contains("order_id 1002"), "{text}");
4518
4519 let mut grid = sql_app(false);
4521 grid.sql_vertical(1);
4522 let text = render_at(&grid, 140).join("\n");
4523 assert!(text.contains("↑/↓ history"), "{text}");
4524 assert!(text.contains("order_id"), "{text}");
4525 }
4526
4527 #[test]
4528 fn sql_grid_view_is_unchanged_and_offers_the_record_toggle() {
4529 let text = render_at(&sql_app(false), 140).join("\n");
4530 assert!(text.contains("order_id"), "{text}");
4532 assert!(text.contains("customer"), "{text}");
4533 assert!(!text.contains("row 2/2"), "{text}");
4534 assert!(text.contains("f2 record"), "{text}");
4535 }
4536
4537 #[test]
4538 fn item_cost_view_explains_an_unattributed_job() {
4539 let data = fetchers::cost::ResourceCost {
4540 kind: fetchers::cost::ResourceKind::Job,
4541 windows: Vec::new(),
4542 months: Vec::new(),
4543 priced: true,
4544 scoped: true,
4545 };
4546 let text = render(&item_cost_app(data)).join("\n");
4547 assert!(text.contains("no usage attributed to this job"), "{text}");
4548 assert!(text.contains("all-purpose compute"), "{text}");
4549 }
4550}