1mod json_highlight;
2pub mod logging;
3pub mod theme;
4
5use std::collections::HashSet;
6use std::io;
7use std::num::NonZeroU32;
8use std::sync::mpsc;
9
10use chrono_humanize::HumanTime;
11use conduit_core::{
12 Config, Direction, Provider, Storages, TransitPage, TransitQuery, TransitRecord,
13};
14use crossterm::ExecutableCommand;
15use crossterm::event::{self, Event, KeyCode, KeyEventKind};
16use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
17use logging::LogBuffer;
18use ratatui::Terminal;
19use ratatui::layout::{Alignment, Constraint, Layout, Rect};
20use ratatui::style::Style;
21use ratatui::text::{Line, Span};
22use ratatui::widgets::{
23 Block, Borders, Cell, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
24 Table, TableState,
25};
26use theme::Theme;
27use uuid::Uuid;
28
29fn format_thousands(n: u64) -> String {
31 let s = n.to_string();
32 let mut result = String::with_capacity(s.len() + s.len() / 3);
33 for (i, c) in s.chars().enumerate() {
34 if i > 0 && (s.len() - i).is_multiple_of(3) {
35 result.push(',');
36 }
37 result.push(c);
38 }
39 result
40}
41
42trait AsF64: Copy {
43 fn as_f64(self) -> f64;
44}
45impl AsF64 for f64 {
46 fn as_f64(self) -> f64 {
47 self
48 }
49}
50impl AsF64 for u64 {
51 fn as_f64(self) -> f64 {
52 self as f64
53 }
54}
55
56#[inline]
57fn update_range<T: AsF64>(range: &mut Option<(T, T)>, value: Option<T>) {
58 if let Some(v) = value {
59 *range = Some(match *range {
60 Some((min, max)) => {
61 let vf = v.as_f64();
62 (
63 if vf < min.as_f64() { v } else { min },
64 if vf > max.as_f64() { v } else { max },
65 )
66 }
67 None => (v, v),
68 });
69 }
70}
71
72fn numeric_cell<'a, T: AsF64>(
76 value: Option<T>,
77 fmt: fn(T) -> String,
78 width: usize,
79 range: Option<(T, T)>,
80 bar_style: Style,
81) -> Cell<'a> {
82 let text = value.map(fmt).unwrap_or_else(|| "-".into());
83 if let (Some(v), Some((min, max))) = (value, range) {
84 let span = max.as_f64() - min.as_f64();
85 let ratio = if span > 0.0 {
86 (v.as_f64() - min.as_f64()) / span
87 } else {
88 1.0
89 };
90 let padded = format!("{text:>width$}");
91 let bar_chars = ((ratio * width as f64).round() as usize).min(width);
92 let (bar_part, rest_part) = padded.split_at(bar_chars);
93 Cell::from(Line::from(vec![
94 Span::styled(bar_part.to_owned(), bar_style),
95 Span::raw(rest_part.to_owned()),
96 ]))
97 } else {
98 Cell::from(Line::from(text).alignment(Alignment::Right))
99 }
100}
101
102struct DisplayTransitRecord<'a> {
104 time: String,
105 provider: &'a Provider,
106 model: Option<&'a str>,
107 cost: Option<f64>,
108 input_tokens: Option<u64>,
109 output_tokens: Option<u64>,
110}
111
112impl<'a> DisplayTransitRecord<'a> {
113 fn from_record(record: &'a TransitRecord, relative_time: bool) -> Self {
114 let input_tokens = record
115 .usage
116 .as_ref()
117 .and_then(|u| u.get("prompt_tokens").or_else(|| u.get("input_tokens")))
118 .and_then(|v| v.as_u64());
119 let output_tokens = record
120 .usage
121 .as_ref()
122 .and_then(|u| {
123 u.get("completion_tokens")
124 .or_else(|| u.get("output_tokens"))
125 })
126 .and_then(|v| v.as_u64());
127 Self {
128 time: if relative_time {
129 HumanTime::from(record.stored_at).to_string()
130 } else {
131 record.stored_at.format("%Y-%m-%d %H:%M:%S").to_string()
132 },
133 provider: &record.provider,
134 model: record.model.as_deref(),
135 cost: record.estimate_cost(),
136 input_tokens,
137 output_tokens,
138 }
139 }
140}
141
142enum View {
143 TransitListing,
144 TransitDetail(usize),
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148enum OpenMenu {
149 File,
150 Help,
151}
152
153pub fn start(
154 config: Config,
155 storages: Storages,
156 log_buffer: LogBuffer,
157 theme: Theme,
158) -> anyhow::Result<()> {
159 let prev_hook = std::panic::take_hook();
160 std::panic::set_hook(Box::new(move |phi| {
161 restore_terminal();
162 prev_hook(phi);
163 }));
164
165 terminal::enable_raw_mode()?;
166 io::stdout().execute(EnterAlternateScreen)?;
167 io::stdout().execute(crossterm::event::EnableMouseCapture)?;
168 let mut terminal = Terminal::new(ratatui::backend::CrosstermBackend::new(io::stdout()))?;
169
170 let mut app = App::new(config, storages, log_buffer, theme);
171 let result = app.run(&mut terminal);
172 restore_terminal();
173
174 result
175}
176
177fn restore_terminal() {
178 let _ = io::stdout().execute(crossterm::cursor::Show);
179 let _ = io::stdout().execute(crossterm::event::DisableMouseCapture);
180 let _ = io::stdout().execute(LeaveAlternateScreen);
181 let _ = terminal::disable_raw_mode();
182}
183
184struct App {
185 _config: Config,
186 storages: Storages,
187 theme: Theme,
188 should_quit: bool,
189 view: View,
190 transit_records: Vec<TransitRecord>,
191 latest_transit_stored_at: Option<chrono::DateTime<chrono::Utc>>,
192 oldest_transit_stored_at: Option<chrono::DateTime<chrono::Utc>>,
193 has_older_records: bool,
194 is_loading_newer: bool,
195 is_loading_older: bool,
196 transit_table_state: TableState,
197 auto_follow: bool,
198 unseen_count: usize,
199 use_relative_time: bool,
200 show_data_bars: bool,
201 transit_detail_scroll: u16,
202 log_buffer: LogBuffer,
203 log_area: Rect,
204 mouse_position: (u16, u16),
205 open_menu: Option<OpenMenu>,
206 show_about: bool,
207 menubar_area: Rect,
208 tick: u64,
209 poll_tx: mpsc::Sender<PollMessage>,
210 poll_rx: mpsc::Receiver<PollMessage>,
211 poll_abort: tokio::task::AbortHandle,
212}
213
214impl App {
215 fn new(_config: Config, storages: Storages, log_buffer: LogBuffer, theme: Theme) -> Self {
216 let (tx, rx) = mpsc::channel();
217
218 let handle = tokio::runtime::Handle::current();
219 let poll_tx = tx.clone();
220 let poll_storages = storages.clone();
221 let task = handle.spawn(poll_loop(poll_storages, tx));
222
223 Self {
224 _config,
225 storages,
226 theme,
227 should_quit: false,
228 view: View::TransitListing,
229 transit_records: Vec::new(),
230 latest_transit_stored_at: None,
231 oldest_transit_stored_at: None,
232 has_older_records: true,
233 is_loading_older: false,
234 transit_table_state: TableState::default(),
235 auto_follow: true,
236 unseen_count: 0,
237 use_relative_time: true,
238 show_data_bars: true,
239 transit_detail_scroll: 0,
240 log_buffer,
241 log_area: Rect::ZERO,
242 mouse_position: (0, 0),
243 open_menu: None,
244 show_about: false,
245 menubar_area: Rect::ZERO,
246 tick: 0,
247 is_loading_newer: true,
248 poll_tx,
249 poll_rx: rx,
250 poll_abort: task.abort_handle(),
251 }
252 }
253
254 fn run(&mut self, terminal: &mut ratatui::DefaultTerminal) -> anyhow::Result<()> {
255 while !self.should_quit {
256 self.process_poll_messages();
257 self.tick = self.tick.wrapping_add(1);
258 terminal.draw(|frame| self.render(frame))?;
259 self.handle_events()?;
260 }
261 self.poll_abort.abort();
262 Ok(())
263 }
264
265 fn render(&mut self, frame: &mut ratatui::Frame) {
266 let area = frame.area();
268 frame.render_widget(Clear, area);
269 frame.render_widget(Block::default().style(self.theme.base), area);
270
271 let has_menubar = self.theme.menubar.is_some();
272 let menubar_height = if has_menubar { 1 } else { 0 };
273
274 let log_lines: Vec<String> = self
275 .log_buffer
276 .lock()
277 .map(|buf| buf.iter().cloned().collect())
278 .unwrap_or_default();
279
280 let log_height = if log_lines.is_empty() {
281 0
282 } else {
283 log_lines.len() as u16 + 2 };
285
286 let chunks = Layout::vertical([
287 Constraint::Length(menubar_height),
288 Constraint::Min(5),
289 Constraint::Length(log_height),
290 ])
291 .split(area);
292 let menubar_area = chunks[0];
293 let content_area = chunks[1];
294 let log_area = chunks[2];
295
296 self.menubar_area = menubar_area;
297 if has_menubar {
298 self.render_menubar(frame, menubar_area);
299 }
300
301 match self.view {
302 View::TransitListing => self.render_transit_listing(frame, content_area),
303 View::TransitDetail(index) => self.render_transit_detail(frame, content_area, index),
304 }
305
306 self.log_area = log_area;
307 self.render_log_panel(frame, log_area, log_lines);
308
309 if has_menubar {
311 self.render_menu_dropdowns(frame, menubar_area);
312 }
313 if self.show_about {
314 self.render_about_dialog(frame, area);
315 }
316 }
317
318 fn render_log_panel(
319 &self,
320 frame: &mut ratatui::Frame,
321 area: ratatui::layout::Rect,
322 log_lines: Vec<String>,
323 ) {
324 if log_lines.is_empty() {
325 return;
326 }
327
328 let theme = &self.theme;
329 let log_text: Vec<Line> = log_lines
330 .into_iter()
331 .map(|line| {
332 let style = if line.contains(" ERROR ") {
333 theme.log_error
334 } else if line.contains(" WARN ") {
335 theme.log_warn
336 } else if line.contains(" INFO ") {
337 theme.log_info
338 } else if line.contains(" DEBUG ") {
339 theme.log_debug
340 } else {
341 theme.log_other
342 };
343 Line::styled(line, style)
344 })
345 .collect();
346
347 let visible = area.height.saturating_sub(2) as usize;
348 let skip = log_text.len().saturating_sub(visible);
349
350 let clear_button_style = if self.is_log_close_hover() {
351 theme.log_close_hover
352 } else {
353 theme.log_close_normal
354 };
355 let log_widget = Paragraph::new(log_text.into_iter().skip(skip).collect::<Vec<_>>()).block(
356 Block::default()
357 .title(Line::from(vec![
358 Span::raw(" Logs "),
359 Span::styled("[ x to clear ]", clear_button_style),
360 Span::raw(" "),
361 ]))
362 .borders(Borders::ALL)
363 .border_style(theme.border)
364 .title_style(theme.title)
365 .title_alignment(theme.title_alignment),
366 );
367
368 frame.render_widget(log_widget, area);
369 }
370
371 fn help_menu_x(&self) -> u16 {
373 self.menubar_area.x + self.menubar_area.width.saturating_sub(HELP_LABEL_WIDTH)
375 }
376
377 fn render_menubar(&self, frame: &mut ratatui::Frame, area: Rect) {
378 let style = self.theme.menubar.unwrap_or(self.theme.base);
379 let hotkey = self.theme.menubar_hotkey;
380 let sel = self.theme.menubar_selected;
381
382 let bar = Paragraph::new("").style(style);
384 frame.render_widget(bar, area);
385
386 let file_style = if self.open_menu == Some(OpenMenu::File) {
387 sel
388 } else {
389 style
390 };
391 let file_hotkey = if self.open_menu == Some(OpenMenu::File) {
392 sel
393 } else {
394 hotkey
395 };
396
397 let file_spans = Line::from(vec![
399 Span::styled(" ", file_style),
400 Span::styled("F", file_hotkey),
401 Span::styled("ile", file_style),
402 Span::styled(" ", file_style),
403 ]);
404 let file_area = Rect::new(area.x, area.y, FILE_LABEL_WIDTH, 1);
405 frame.render_widget(Paragraph::new(file_spans), file_area);
406
407 let help_style = if self.open_menu == Some(OpenMenu::Help) {
409 sel
410 } else {
411 style
412 };
413 let help_hotkey = if self.open_menu == Some(OpenMenu::Help) {
414 sel
415 } else {
416 hotkey
417 };
418
419 let help_x = self.help_menu_x();
420 let help_spans = Line::from(vec![
421 Span::styled(" ", help_style),
422 Span::styled("H", help_hotkey),
423 Span::styled("elp", help_style),
424 Span::styled(" ", help_style),
425 ]);
426 let help_area = Rect::new(help_x, area.y, HELP_LABEL_WIDTH, 1);
427 frame.render_widget(Paragraph::new(help_spans), help_area);
428
429 let (decoration, dec_width) = self.theme.menubar_decoration.frame(self.tick);
431 if dec_width > 0 {
432 let dec_style = self.theme.menubar_decoration_style;
433 let avail_start = area.x + FILE_LABEL_WIDTH;
434 let avail_end = help_x;
435 if avail_end > avail_start + dec_width {
436 let mid = avail_start + (avail_end - avail_start - dec_width) / 2;
438 let dec_area = Rect::new(mid, area.y, dec_width, 1);
439 frame.render_widget(
440 Paragraph::new(Span::styled(decoration, dec_style)),
441 dec_area,
442 );
443 }
444 }
445 }
446
447 fn render_menu_dropdowns(&self, frame: &mut ratatui::Frame, menubar_area: Rect) {
448 let style = self.theme.menu_dropdown;
449 let sel = self.theme.menu_dropdown_selected;
450
451 match self.open_menu {
452 Some(OpenMenu::File) => {
453 let drop = Rect::new(
455 menubar_area.x,
456 menubar_area.y + 1,
457 12, 3, );
460 let is_hover = self.menu_dropdown_hover(drop) == Some(0);
461 let item_style = if is_hover { sel } else { style };
462 let block = Block::default().borders(Borders::ALL).style(style);
463 frame.render_widget(Clear, drop);
464 frame.render_widget(block, drop);
465 let item_area = Rect::new(drop.x + 1, drop.y + 1, drop.width - 2, 1);
466 frame.render_widget(
467 Paragraph::new(Line::from(vec![Span::styled(" Quit ", item_style)])),
468 item_area,
469 );
470 }
471 Some(OpenMenu::Help) => {
472 let help_x = self.help_menu_x();
474 let drop_x = help_x.saturating_sub(12 - HELP_LABEL_WIDTH);
475 let drop = Rect::new(drop_x, menubar_area.y + 1, 12, 3);
476 let is_hover = self.menu_dropdown_hover(drop) == Some(0);
477 let item_style = if is_hover { sel } else { style };
478 let block = Block::default().borders(Borders::ALL).style(style);
479 frame.render_widget(Clear, drop);
480 frame.render_widget(block, drop);
481 let item_area = Rect::new(drop.x + 1, drop.y + 1, drop.width - 2, 1);
482 frame.render_widget(
483 Paragraph::new(Line::from(vec![Span::styled(" About ", item_style)])),
484 item_area,
485 );
486 }
487 None => {}
488 }
489 }
490
491 fn render_about_dialog(&self, frame: &mut ratatui::Frame, area: Rect) {
492 let w = 40u16;
493 let h = 7u16;
494 let x = area.x + area.width.saturating_sub(w) / 2;
495 let y = area.y + area.height.saturating_sub(h) / 2;
496 let dialog = Rect::new(x, y, w.min(area.width), h.min(area.height));
497
498 let style = self.theme.menu_dropdown;
499 let title_style = self.theme.title;
500
501 frame.render_widget(Clear, dialog);
502 let text = vec![
503 Line::raw(""),
504 Line::from("Conduit").alignment(Alignment::Center),
505 Line::raw(""),
506 Line::from("API Transit Dashboard").alignment(Alignment::Center),
507 Line::from("Press any key to close").alignment(Alignment::Center),
508 ];
509 let block = Block::default()
510 .title(" About ")
511 .title_alignment(Alignment::Center)
512 .title_style(title_style)
513 .borders(Borders::ALL)
514 .style(style);
515 frame.render_widget(Paragraph::new(text).block(block), dialog);
516 }
517
518 fn menu_dropdown_hover(&self, drop: Rect) -> Option<usize> {
520 let (mx, my) = self.mouse_position;
521 if mx > drop.x
523 && mx < drop.x + drop.width - 1
524 && my > drop.y
525 && my < drop.y + drop.height - 1
526 {
527 Some((my - drop.y - 1) as usize)
528 } else {
529 None
530 }
531 }
532
533 fn render_transit_listing(&mut self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
534 let [table_area, status_area] =
535 Layout::vertical([Constraint::Min(5), Constraint::Length(1)]).areas(area);
536
537 let theme = &self.theme;
538
539 let header = Row::new(vec![
540 Cell::from("Time"),
541 Cell::from("Provider"),
542 Cell::from("Model"),
543 Cell::from(Line::from("Cost").alignment(Alignment::Right)),
544 Cell::from(Line::from("Input Tokens").alignment(Alignment::Right)),
545 Cell::from(Line::from("Output Tokens").alignment(Alignment::Right)),
546 ])
547 .style(theme.table_header);
548
549 let bar_style = theme.data_bar;
550
551 let mut cost_range: Option<(f64, f64)> = None;
552 let mut input_range: Option<(u64, u64)> = None;
553 let mut output_range: Option<(u64, u64)> = None;
554 let display_records: Vec<DisplayTransitRecord> = self
555 .transit_records
556 .iter()
557 .map(|r| {
558 let dr = DisplayTransitRecord::from_record(r, self.use_relative_time);
559 if self.show_data_bars {
560 update_range(&mut cost_range, dr.cost);
561 update_range(&mut input_range, dr.input_tokens);
562 update_range(&mut output_range, dr.output_tokens);
563 }
564 dr
565 })
566 .collect();
567
568 let mut rows: Vec<Row> = display_records
569 .iter()
570 .map(|dr| {
571 Row::new(vec![
572 Cell::from(dr.time.as_str()),
573 Cell::from(dr.provider.to_string()),
574 Cell::from(dr.model.unwrap_or("-")),
575 numeric_cell(dr.cost, |c| format!("${:.5}", c), 12, cost_range, bar_style),
576 numeric_cell(
577 dr.input_tokens,
578 format_thousands,
579 12,
580 input_range,
581 bar_style,
582 ),
583 numeric_cell(
584 dr.output_tokens,
585 format_thousands,
586 13,
587 output_range,
588 bar_style,
589 ),
590 ])
591 })
592 .collect();
593
594 if self.is_loading_newer {
595 rows.push(
596 Row::new(vec![Cell::from(""), Cell::from("Loading...")]).style(theme.loading),
597 );
598 }
599
600 let widths = [
601 Constraint::Length(19),
602 Constraint::Length(12),
603 Constraint::Length(20),
604 Constraint::Length(12), Constraint::Length(12),
606 Constraint::Length(13),
607 ];
608 let title = if self.unseen_count > 0 {
609 Line::from(vec![
610 Span::raw(" Requests "),
611 Span::styled(
612 theme
613 .unseen_badge_fmt
614 .replace("{}", &self.unseen_count.to_string()),
615 theme.unseen_badge,
616 ),
617 ])
618 } else {
619 Line::from(" Requests ")
620 };
621 let table = Table::new(rows, widths)
622 .header(header)
623 .block(
624 Block::default()
625 .title(title)
626 .borders(Borders::ALL)
627 .border_style(theme.border)
628 .title_style(theme.title)
629 .title_alignment(theme.title_alignment),
630 )
631 .row_highlight_style(theme.row_highlight);
632
633 frame.render_stateful_widget(table, table_area, &mut self.transit_table_state);
634
635 let content_len = self.transit_records.len();
636 let viewport = self.visible_table_rows(table_area.height);
637 let scroll_pos = self.transit_table_state.selected().unwrap_or(0);
638 let mut scrollbar_state = ScrollbarState::new(content_len.saturating_sub(viewport))
639 .position(scroll_pos.saturating_sub(viewport / 2));
640 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
641 frame.render_stateful_widget(scrollbar, table_area, &mut scrollbar_state);
642
643 let status = format!(
644 "Enter/Right/l: details, q: quit, f: go to latest, t: {}, b: data bars {}",
645 if self.use_relative_time {
646 "absolute times"
647 } else {
648 "relative times"
649 },
650 if self.show_data_bars { "off" } else { "on" },
651 );
652
653 frame.render_widget(Paragraph::new(status).style(theme.status), status_area);
654 }
655
656 fn visible_table_rows(&self, area_height: u16) -> usize {
657 area_height.saturating_sub(3) as usize }
659
660 fn render_transit_detail(
661 &mut self,
662 frame: &mut ratatui::Frame,
663 area: ratatui::layout::Rect,
664 index: usize,
665 ) {
666 let [content_area, status_area] =
667 Layout::vertical([Constraint::Min(5), Constraint::Length(1)]).areas(area);
668
669 let theme = &self.theme;
670 let record = &self.transit_records[index];
671
672 let label_style = theme.label;
673
674 let mut lines = vec![
675 Line::from(vec![
676 Span::styled("Transit ID: ", label_style),
677 Span::raw(record.transit_id.to_string()),
678 ]),
679 Line::from(vec![
680 Span::styled("Time: ", label_style),
681 Span::raw(
682 record
683 .stored_at
684 .format("%Y-%m-%d %H:%M:%S%.6f UTC")
685 .to_string(),
686 ),
687 ]),
688 Line::from(vec![
689 Span::styled("Provider: ", label_style),
690 Span::raw(record.provider.to_string()),
691 ]),
692 Line::from(vec![
693 Span::styled("Model: ", label_style),
694 Span::raw(record.model.as_deref().unwrap_or("-").to_string()),
695 ]),
696 Line::from(vec![
697 Span::styled("Header ID: ", label_style),
698 Span::raw(record.header_id.as_deref().unwrap_or("-").to_string()),
699 ]),
700 Line::from(vec![
701 Span::styled("Body ID: ", label_style),
702 Span::raw(record.body_id.as_deref().unwrap_or("-").to_string()),
703 ]),
704 Line::from(vec![
705 Span::styled("Est. Cost: ", label_style),
706 Span::raw(
707 record
708 .estimate_cost()
709 .map(|c| format!("${:.6}", c))
710 .unwrap_or_else(|| "-".to_string()),
711 ),
712 ]),
713 ];
714
715 if let Some(ref vh) = record.vh_headers {
716 lines.push(Line::raw(""));
717 lines.push(Line::from(Span::styled("Valohai Headers:", label_style)));
718 let mut keys: Vec<_> = vh.keys().collect();
719 keys.sort();
720 for key in keys {
721 lines.push(Line::from(vec![
722 Span::styled(format!(" {}: ", key), label_style),
723 Span::raw(vh[key].clone()),
724 ]));
725 }
726 }
727
728 if let Some(ref usage) = record.usage {
729 lines.push(Line::raw(""));
730 lines.push(Line::from(Span::styled("Full Usage:", label_style)));
731 lines.extend(json_highlight::json_to_lines(usage, theme));
732 }
733
734 let content_len = lines.len();
735 let viewport = content_area.height.saturating_sub(2) as usize;
736 let max_scroll = content_len.saturating_sub(viewport) as u16;
737 self.transit_detail_scroll = self.transit_detail_scroll.min(max_scroll);
738 let detail = Paragraph::new(lines)
739 .block(
740 Block::default()
741 .title(" Request Details ")
742 .borders(Borders::ALL)
743 .border_style(theme.border)
744 .title_style(theme.title)
745 .title_alignment(theme.title_alignment),
746 )
747 .scroll((self.transit_detail_scroll, 0));
748 frame.render_widget(detail, content_area);
749
750 let mut scrollbar_state = ScrollbarState::new(content_len.saturating_sub(viewport))
751 .position(self.transit_detail_scroll as usize);
752 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
753 frame.render_stateful_widget(scrollbar, content_area, &mut scrollbar_state);
754
755 let position = format!(" {}/{} ", index + 1, self.transit_records.len());
756 let status = format!(
757 "{}| Backspace/Left/h: back, Up/Down: scroll, [: previvous, ]: next",
758 position,
759 );
760 frame.render_widget(Paragraph::new(status).style(theme.status), status_area);
761 }
762
763 fn handle_events(&mut self) -> anyhow::Result<()> {
764 if event::poll(std::time::Duration::from_millis(100))? {
765 let ev = event::read()?;
766 if let Event::Mouse(mouse) = &ev {
767 self.mouse_position = (mouse.column, mouse.row);
768 }
769
770 if self.show_about {
772 if matches!(ev, Event::Key(key) if key.kind == KeyEventKind::Press)
773 || matches!(ev, Event::Mouse(m) if m.kind == event::MouseEventKind::Down(event::MouseButton::Left))
774 {
775 self.show_about = false;
776 }
777 return Ok(());
778 }
779
780 if self.handle_menu_events(&ev)? {
782 return Ok(());
783 }
784
785 match self.view {
786 View::TransitListing => self.handle_listing_events(ev)?,
787 View::TransitDetail(_) => self.handle_detail_events(ev)?,
788 }
789 }
790 Ok(())
791 }
792
793 fn handle_menu_events(&mut self, ev: &Event) -> anyhow::Result<bool> {
795 let has_menubar = self.theme.menubar.is_some();
796
797 if let Event::Key(key) = ev
799 && key.kind == KeyEventKind::Press
800 && has_menubar
801 {
802 if key.modifiers.contains(event::KeyModifiers::ALT) {
803 match key.code {
804 KeyCode::Char('f') | KeyCode::Char('F') => {
805 self.open_menu = if self.open_menu == Some(OpenMenu::File) {
806 None
807 } else {
808 Some(OpenMenu::File)
809 };
810 return Ok(true);
811 }
812 KeyCode::Char('h') | KeyCode::Char('H') => {
813 self.open_menu = if self.open_menu == Some(OpenMenu::Help) {
814 None
815 } else {
816 Some(OpenMenu::Help)
817 };
818 return Ok(true);
819 }
820 _ => {}
821 }
822 }
823
824 if self.open_menu.is_some() {
826 match key.code {
827 KeyCode::Esc => {
828 self.open_menu = None;
829 return Ok(true);
830 }
831 KeyCode::Left | KeyCode::Right => {
832 self.open_menu = Some(match self.open_menu {
833 Some(OpenMenu::File) => OpenMenu::Help,
834 _ => OpenMenu::File,
835 });
836 return Ok(true);
837 }
838 KeyCode::Enter => {
839 match self.open_menu {
840 Some(OpenMenu::File) => self.should_quit = true,
841 Some(OpenMenu::Help) => {
842 self.show_about = true;
843 self.open_menu = None;
844 }
845 None => {}
846 }
847 return Ok(true);
848 }
849 _ => {
850 self.open_menu = None;
851 return Ok(true);
852 }
853 }
854 }
855 }
856
857 if let Event::Mouse(mouse) = ev {
859 if mouse.kind == event::MouseEventKind::Down(event::MouseButton::Left) && has_menubar {
860 let (mx, my) = (mouse.column, mouse.row);
861
862 if my == 0 {
864 let help_x = self.help_menu_x();
865 if mx < FILE_LABEL_WIDTH {
866 self.open_menu = if self.open_menu == Some(OpenMenu::File) {
868 None
869 } else {
870 Some(OpenMenu::File)
871 };
872 return Ok(true);
873 } else if mx >= help_x && mx < help_x + HELP_LABEL_WIDTH {
874 self.open_menu = if self.open_menu == Some(OpenMenu::Help) {
876 None
877 } else {
878 Some(OpenMenu::Help)
879 };
880 return Ok(true);
881 } else if self.open_menu.is_some() {
882 self.open_menu = None;
883 return Ok(true);
884 }
885 }
886
887 if self.open_menu == Some(OpenMenu::File) {
889 let drop = Rect::new(0, 1, 12, 3);
890 if let Some(0) = self.menu_dropdown_hover_at(drop, mx, my) {
891 self.should_quit = true;
892 return Ok(true);
893 } else {
894 self.open_menu = None;
895 return Ok(true);
896 }
897 }
898 if self.open_menu == Some(OpenMenu::Help) {
899 let help_x = self.help_menu_x();
900 let drop_x = help_x.saturating_sub(12 - HELP_LABEL_WIDTH);
901 let drop = Rect::new(drop_x, 1, 12, 3);
902 if let Some(0) = self.menu_dropdown_hover_at(drop, mx, my) {
903 self.show_about = true;
904 self.open_menu = None;
905 return Ok(true);
906 } else {
907 self.open_menu = None;
908 return Ok(true);
909 }
910 }
911 }
912
913 if mouse.kind == event::MouseEventKind::Down(event::MouseButton::Left)
915 && self.open_menu.is_some()
916 {
917 self.open_menu = None;
918 return Ok(true);
919 }
920 }
921
922 Ok(false)
923 }
924
925 fn menu_dropdown_hover_at(&self, drop: Rect, mx: u16, my: u16) -> Option<usize> {
926 if mx > drop.x
927 && mx < drop.x + drop.width - 1
928 && my > drop.y
929 && my < drop.y + drop.height - 1
930 {
931 Some((my - drop.y - 1) as usize)
932 } else {
933 None
934 }
935 }
936
937 fn handle_listing_events(&mut self, ev: Event) -> anyhow::Result<()> {
938 match ev {
939 Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
940 KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
941 KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
942 self.should_quit = true;
943 }
944 KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
945 self.open_selected_detail();
946 }
947 KeyCode::Up | KeyCode::Char('k') => {
948 let i = self.transit_table_state.selected().unwrap_or(0);
949 let prev = i.saturating_sub(1);
950 self.transit_table_state.select(Some(prev));
951 if prev == 0 {
952 self.auto_follow = true;
953 self.unseen_count = 0;
954 }
955 }
956 KeyCode::Down | KeyCode::Char('j') => {
957 self.auto_follow = false;
958 let i = self.transit_table_state.selected().unwrap_or(0);
959 let next = (i + 1).min(self.transit_records.len().saturating_sub(1));
960 self.transit_table_state.select(Some(next));
961 self.request_older_records_if_needed();
962 }
963 KeyCode::PageUp => {
964 let i = self.transit_table_state.selected().unwrap_or(0);
965 let prev = i.saturating_sub(PG_BUTTON_JUMP);
966 self.transit_table_state.select(Some(prev));
967 if prev == 0 {
968 self.auto_follow = true;
969 self.unseen_count = 0;
970 }
971 }
972 KeyCode::PageDown => {
973 self.auto_follow = false;
974 let i = self.transit_table_state.selected().unwrap_or(0);
975 let next =
976 (i + PG_BUTTON_JUMP).min(self.transit_records.len().saturating_sub(1));
977 self.transit_table_state.select(Some(next));
978 self.request_older_records_if_needed();
979 }
980 KeyCode::Home => {
981 self.auto_follow = true;
982 self.select_first();
983 }
984 KeyCode::End => {
985 self.auto_follow = false;
986 if !self.transit_records.is_empty() {
987 self.transit_table_state
988 .select(Some(self.transit_records.len() - 1));
989 }
990 self.request_older_records_if_needed();
991 }
992 KeyCode::Char('t') | KeyCode::Char('T') => {
993 self.use_relative_time = !self.use_relative_time;
994 }
995 KeyCode::Char('b') | KeyCode::Char('B') => {
996 self.show_data_bars = !self.show_data_bars;
997 }
998 KeyCode::Char('f') | KeyCode::Char('F') => {
999 self.auto_follow = !self.auto_follow;
1000 if self.auto_follow {
1001 self.select_first();
1002 }
1003 }
1004 KeyCode::Char('x') => {
1005 self.clear_logs();
1006 }
1007 _ => {}
1008 },
1009 Event::Mouse(mouse) => match mouse.kind {
1010 event::MouseEventKind::ScrollUp => {
1011 let i = self.transit_table_state.selected().unwrap_or(0);
1012 let prev = i.saturating_sub(1);
1013 self.transit_table_state.select(Some(prev));
1014 if prev == 0 {
1015 self.auto_follow = true;
1016 self.unseen_count = 0;
1017 }
1018 }
1019 event::MouseEventKind::ScrollDown => {
1020 self.auto_follow = false;
1021 let i = self.transit_table_state.selected().unwrap_or(0);
1022 let next = (i + 1).min(self.transit_records.len().saturating_sub(1));
1023 self.transit_table_state.select(Some(next));
1024 self.request_older_records_if_needed();
1025 }
1026 event::MouseEventKind::Down(event::MouseButton::Left) => {
1027 if self.is_log_close_hit(mouse.column, mouse.row) {
1028 self.clear_logs();
1029 }
1030 }
1031 _ => {}
1032 },
1033 _ => {}
1034 }
1035 Ok(())
1036 }
1037
1038 fn clear_logs(&mut self) {
1039 if let Ok(mut buf) = self.log_buffer.lock() {
1040 buf.clear();
1041 }
1042 }
1043
1044 fn is_log_close_hit(&self, column: u16, row: u16) -> bool {
1045 let area = self.log_area;
1046 if area.height == 0 {
1047 return false;
1048 }
1049 row == area.y && column >= area.x + 7 && column < area.x + 21
1051 }
1052
1053 fn is_log_close_hover(&self) -> bool {
1054 let (col, row) = self.mouse_position;
1055 self.is_log_close_hit(col, row)
1056 }
1057
1058 fn open_selected_detail(&mut self) {
1059 if let Some(index) = self.transit_table_state.selected()
1060 && index < self.transit_records.len()
1061 {
1062 self.transit_detail_scroll = 0;
1063 self.view = View::TransitDetail(index);
1064 }
1065 }
1066
1067 fn handle_detail_events(&mut self, ev: Event) -> anyhow::Result<()> {
1068 match ev {
1069 Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
1070 KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
1071 KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1072 self.should_quit = true;
1073 }
1074 KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') => {
1075 self.view = View::TransitListing;
1076 }
1077 KeyCode::Up | KeyCode::Char('k') => {
1078 self.transit_detail_scroll = self.transit_detail_scroll.saturating_sub(1);
1079 }
1080 KeyCode::Down | KeyCode::Char('j') => {
1081 self.transit_detail_scroll = self.transit_detail_scroll.saturating_add(1);
1082 }
1083 KeyCode::PageUp => {
1084 self.transit_detail_scroll = self
1085 .transit_detail_scroll
1086 .saturating_sub(PG_BUTTON_JUMP as u16);
1087 }
1088 KeyCode::PageDown => {
1089 self.transit_detail_scroll = self
1090 .transit_detail_scroll
1091 .saturating_add(PG_BUTTON_JUMP as u16);
1092 }
1093 KeyCode::Home => {
1094 self.transit_detail_scroll = 0;
1095 }
1096 KeyCode::End => {
1097 self.transit_detail_scroll = u16::MAX;
1098 }
1099 KeyCode::Char('[') => {
1100 if let View::TransitDetail(index) = self.view
1101 && index > 0
1102 {
1103 let new_index = index - 1;
1104 self.transit_detail_scroll = 0;
1105 self.view = View::TransitDetail(new_index);
1106 self.transit_table_state.select(Some(new_index));
1107 }
1108 }
1109 KeyCode::Char(']') => {
1110 if let View::TransitDetail(index) = self.view {
1111 let max = self.transit_records.len().saturating_sub(1);
1112 if index < max {
1113 let new_index = index + 1;
1114 self.transit_detail_scroll = 0;
1115 self.view = View::TransitDetail(new_index);
1116 self.transit_table_state.select(Some(new_index));
1117 }
1118 }
1119 }
1120 _ => {}
1121 },
1122 Event::Mouse(mouse) => match mouse.kind {
1123 event::MouseEventKind::ScrollUp => {
1124 self.transit_detail_scroll = self.transit_detail_scroll.saturating_sub(3);
1125 }
1126 event::MouseEventKind::ScrollDown => {
1127 self.transit_detail_scroll = self.transit_detail_scroll.saturating_add(3);
1128 }
1129 event::MouseEventKind::Down(event::MouseButton::Left) => {
1130 if self.is_log_close_hit(mouse.column, mouse.row) {
1131 self.clear_logs();
1132 }
1133 }
1134 _ => {}
1135 },
1136 _ => {}
1137 }
1138 Ok(())
1139 }
1140
1141 fn process_poll_messages(&mut self) {
1142 while let Ok(msg) = self.poll_rx.try_recv() {
1143 match msg {
1144 PollMessage::Loading => {
1145 self.is_loading_newer = true;
1146 }
1147 PollMessage::Page(page) => {
1148 self.is_loading_newer = false;
1149 if self.oldest_transit_stored_at.is_none() {
1150 self.has_older_records = page.has_more;
1151 }
1152 let new_records: Vec<_> = page
1153 .records
1154 .into_iter()
1155 .filter(|r| {
1156 !self
1157 .transit_records
1158 .iter()
1159 .any(|existing| existing.transit_id == r.transit_id)
1160 })
1161 .collect();
1162 if !new_records.is_empty() {
1163 self.latest_transit_stored_at = new_records.first().map(|r| r.stored_at);
1164 if self.oldest_transit_stored_at.is_none() {
1165 self.oldest_transit_stored_at = new_records.last().map(|r| r.stored_at);
1166 }
1167 let new_record_count = new_records.len();
1168 self.transit_records.splice(0..0, new_records);
1169 let in_detail = matches!(self.view, View::TransitDetail(_));
1170 if !self.auto_follow || in_detail {
1171 self.unseen_count += new_record_count;
1172 }
1173 if self.auto_follow && !in_detail {
1174 self.select_first();
1175 } else {
1176 let current_offset = self.transit_table_state.offset();
1177 *self.transit_table_state.offset_mut() =
1178 current_offset + new_record_count;
1179 if let Some(selected) = self.transit_table_state.selected() {
1180 self.transit_table_state
1181 .select(Some(selected + new_record_count));
1182 }
1183 if let View::TransitDetail(ref mut index) = self.view {
1184 *index += new_record_count;
1185 }
1186 }
1187 }
1188 }
1189 PollMessage::OlderPage(page) => {
1190 self.is_loading_older = false;
1191 self.has_older_records = page.has_more;
1192 let new_records: Vec<_> = page
1193 .records
1194 .into_iter()
1195 .filter(|r| {
1196 !self
1197 .transit_records
1198 .iter()
1199 .any(|existing| existing.transit_id == r.transit_id)
1200 })
1201 .collect();
1202 if !new_records.is_empty() {
1203 self.oldest_transit_stored_at = new_records.last().map(|r| r.stored_at);
1204 self.transit_records.extend(new_records);
1205 }
1206 }
1207 PollMessage::Backfill(updated_records) => {
1208 for updated in updated_records {
1209 if let Some(existing) = self
1210 .transit_records
1211 .iter_mut()
1212 .find(|r| r.transit_id == updated.transit_id)
1213 {
1214 existing.model = updated.model;
1215 existing.usage = updated.usage;
1216 }
1217 }
1218 }
1219 PollMessage::Error(err) => {
1220 self.is_loading_newer = false;
1221 self.is_loading_older = false;
1222 tracing::error!("{}", err);
1223 }
1224 }
1225 }
1226 }
1227
1228 fn select_first(&mut self) {
1229 self.unseen_count = 0;
1230 if self.transit_records.is_empty() {
1231 self.transit_table_state.select(None);
1232 } else {
1233 self.transit_table_state.select(Some(0));
1234 }
1235 }
1236
1237 fn request_older_records_if_needed(&mut self) {
1238 let selected = self.transit_table_state.selected().unwrap_or(0);
1239 let at_end = !self.transit_records.is_empty()
1240 && selected >= self.transit_records.len().saturating_sub(1);
1241
1242 if !at_end || !self.has_older_records || self.is_loading_older {
1243 return;
1244 }
1245 self.is_loading_older = true;
1246
1247 let cursor = self
1248 .oldest_transit_stored_at
1249 .or_else(|| self.transit_records.last().map(|r| r.stored_at));
1250
1251 let storages = self.storages.clone();
1252 let tx = self.poll_tx.clone();
1253 let handle = tokio::runtime::Handle::current();
1254 handle.spawn(async move {
1255 let limit = NonZeroU32::new(OLDER_PAGE_SIZE).unwrap();
1256 let result = storages
1257 .transit
1258 .list_transits(TransitQuery {
1259 cursor,
1260 direction: Direction::Before,
1261 limit,
1262 })
1263 .await;
1264 match result {
1265 Ok(page) => {
1266 let _ = tx.send(PollMessage::OlderPage(page));
1267 }
1268 Err(e) => {
1269 let _ = tx.send(PollMessage::Error(e.to_string()));
1270 }
1271 }
1272 });
1273 }
1274}
1275
1276const PG_BUTTON_JUMP: usize = 10;
1277const FILE_LABEL_WIDTH: u16 = 6; const HELP_LABEL_WIDTH: u16 = 6; const INITIAL_PAGE_SIZE: u32 = 50;
1280const OLDER_PAGE_SIZE: u32 = 25;
1281const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
1282
1283enum PollMessage {
1284 Loading,
1285 Page(TransitPage),
1286 OlderPage(TransitPage),
1287 Backfill(Vec<TransitRecord>),
1288 Error(String),
1289}
1290
1291async fn poll_loop(storages: Storages, tx: mpsc::Sender<PollMessage>) {
1292 let limit = NonZeroU32::new(INITIAL_PAGE_SIZE).unwrap();
1293 let mut incomplete_ids: HashSet<Uuid> = HashSet::new();
1294
1295 let _ = tx.send(PollMessage::Loading);
1296 let initial = storages
1297 .transit
1298 .list_transits(TransitQuery {
1299 cursor: None,
1300 direction: Direction::Before,
1301 limit,
1302 })
1303 .await;
1304
1305 let mut cursor = match initial {
1306 Ok(page) => {
1307 let stored_at = page.records.first().map(|r| r.stored_at);
1308 track_incomplete(&mut incomplete_ids, &page.records);
1309 let _ = tx.send(PollMessage::Page(page));
1310 stored_at
1311 }
1312 Err(e) => {
1313 let _ = tx.send(PollMessage::Error(e.to_string()));
1314 None
1315 }
1316 };
1317
1318 loop {
1319 tokio::time::sleep(POLL_INTERVAL).await;
1320
1321 let _ = tx.send(PollMessage::Loading);
1322
1323 if !incomplete_ids.is_empty() {
1324 let ids: Vec<Uuid> = incomplete_ids.iter().copied().collect();
1325 if let Ok(records) = storages.transit.get_transits(ids).await {
1326 let filled: Vec<TransitRecord> =
1327 records.into_iter().filter(|r| r.usage.is_some()).collect();
1328 for r in &filled {
1329 incomplete_ids.remove(&r.transit_id);
1330 }
1331 if !filled.is_empty() {
1332 let _ = tx.send(PollMessage::Backfill(filled));
1333 }
1334 }
1335 }
1336
1337 let result = storages
1338 .transit
1339 .list_transits(TransitQuery {
1340 cursor,
1341 direction: Direction::After,
1342 limit,
1343 })
1344 .await;
1345
1346 match result {
1347 Ok(page) => {
1348 if let Some(first) = page.records.first() {
1349 cursor = Some(first.stored_at);
1350 }
1351 track_incomplete(&mut incomplete_ids, &page.records);
1352 let _ = tx.send(PollMessage::Page(page));
1353 }
1354 Err(e) => {
1355 let _ = tx.send(PollMessage::Error(e.to_string()));
1356 }
1357 }
1358 }
1359}
1360
1361fn track_incomplete(incomplete_ids: &mut HashSet<Uuid>, records: &[TransitRecord]) {
1362 for record in records {
1363 if record.usage.is_none() {
1365 incomplete_ids.insert(record.transit_id);
1366 } else {
1367 incomplete_ids.remove(&record.transit_id);
1368 }
1369 }
1370}