18.6 KiB · text History b868907
//! The interactive CI dashboard.
//!
//! Three panes over one repository: runs on the left, the selected run's
//! jobs and steps on the right, and a log viewer that takes the whole
//! width once you open one. It polls on a timer and on demand, and can
//! re-run or cancel — the same CI surface the phone's Actions tab has,
//! with a keyboard instead of a thumb.
//!
//! Fetches block the draw loop. They're single small JSON responses
//! against one forge, so the pause isn't perceptible; if that stops being
//! true the fix is to move each call onto its own task and feed results
//! back through the same channel the key events already use.

use anyhow::Result;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{DefaultTerminal, Frame};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;

use crate::api::models::{Job, LogsResponse, Run, RunDetailResponse};
use crate::api::{ApiFailure, Client, RepoRef};
use crate::cli::DashboardArgs;
use crate::commands::Ctx;
use crate::output::{ago, duration, effective_status, status_label, status_mark};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
    Runs,
    Jobs,
    Logs,
}

struct App {
    repo: RepoRef,
    limit: u32,
    runs: Vec<Run>,
    run_cursor: usize,
    detail: Option<RunDetailResponse>,
    job_cursor: usize,
    logs: Option<LogsResponse>,
    log_scroll: u16,
    focus: Focus,
    message: String,
    last_refresh: Option<Instant>,
}

impl App {
    fn selected_run(&self) -> Option<&Run> {
        self.runs.get(self.run_cursor)
    }

    fn selected_job(&self) -> Option<&Job> {
        self.detail.as_ref()?.jobs.get(self.job_cursor)
    }

    fn move_cursor(&mut self, delta: isize) {
        let (cursor, len) = match self.focus {
            Focus::Runs => (&mut self.run_cursor, self.runs.len()),
            Focus::Jobs => (
                &mut self.job_cursor,
                self.detail.as_ref().map(|d| d.jobs.len()).unwrap_or(0),
            ),
            Focus::Logs => {
                self.log_scroll = self.log_scroll.saturating_add_signed(delta as i16);
                return;
            }
        };
        if len == 0 {
            return;
        }
        let next = (*cursor as isize + delta).clamp(0, len as isize - 1);
        *cursor = next as usize;
    }
}

pub async fn run(ctx: &Ctx, args: &DashboardArgs) -> Result<()> {
    let repo = ctx.repo()?;
    let mut app = App {
        repo,
        limit: args.limit,
        runs: Vec::new(),
        run_cursor: 0,
        detail: None,
        job_cursor: 0,
        logs: None,
        log_scroll: 0,
        focus: Focus::Runs,
        message: "loading…".into(),
        last_refresh: None,
    };

    let mut terminal = ratatui::init();
    let outcome = event_loop(&mut terminal, &mut app, &ctx.api, args.refresh).await;
    ratatui::restore();
    outcome
}

async fn event_loop(
    terminal: &mut DefaultTerminal,
    app: &mut App,
    api: &Client,
    refresh: u64,
) -> Result<()> {
    // Key reads live on their own thread so a slow request can't swallow
    // a keystroke, and a quiet terminal costs nothing.
    let (tx, mut rx) = mpsc::unbounded_channel::<Event>();
    std::thread::spawn(move || {
        while let Ok(event) = event::read() {
            if tx.send(event).is_err() {
                break;
            }
        }
    });

    refresh_runs(app, api).await;
    terminal.draw(|frame| draw(frame, app))?;

    let mut ticker = tokio::time::interval(Duration::from_secs(refresh.max(1)));
    ticker.tick().await; // the first tick is immediate

    loop {
        tokio::select! {
            Some(event) = rx.recv() => {
                match event {
                    Event::Key(key) if key.kind == KeyEventKind::Press => {
                        if handle_key(app, api, key).await? {
                            return Ok(());
                        }
                    }
                    Event::Resize(_, _) => {}
                    _ => continue,
                }
            }
            _ = ticker.tick() => {
                refresh_runs(app, api).await;
                reload_detail(app, api).await;
            }
        }
        terminal.draw(|frame| draw(frame, app))?;
    }
}

/// Returns true when the user has asked to leave.
async fn handle_key(app: &mut App, api: &Client, key: KeyEvent) -> Result<bool> {
    match key.code {
        KeyCode::Char('q') => return Ok(true),
        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return Ok(true),

        KeyCode::Esc => match app.focus {
            Focus::Logs => {
                app.logs = None;
                app.focus = Focus::Jobs;
            }
            Focus::Jobs => app.focus = Focus::Runs,
            Focus::Runs => return Ok(true),
        },

        KeyCode::Char('j') | KeyCode::Down => app.move_cursor(1),
        KeyCode::Char('k') | KeyCode::Up => app.move_cursor(-1),
        KeyCode::PageDown => app.move_cursor(10),
        KeyCode::PageUp => app.move_cursor(-10),
        KeyCode::Home if app.focus == Focus::Logs => app.log_scroll = 0,

        KeyCode::Tab => {
            app.focus = match app.focus {
                Focus::Runs if app.detail.is_some() => Focus::Jobs,
                Focus::Runs => Focus::Runs,
                Focus::Jobs | Focus::Logs => Focus::Runs,
            }
        }

        KeyCode::Enter => match app.focus {
            Focus::Runs => {
                reload_detail(app, api).await;
                if app.detail.as_ref().is_some_and(|d| !d.jobs.is_empty()) {
                    app.focus = Focus::Jobs;
                }
            }
            Focus::Jobs => open_logs(app, api).await,
            Focus::Logs => {}
        },

        KeyCode::Char('l') => open_logs(app, api).await,

        KeyCode::Char('r') => {
            refresh_runs(app, api).await;
            reload_detail(app, api).await;
        }

        // Write actions are capitalized, so neither is a neighbour of a
        // navigation key: re-running someone else's release because you
        // reached for `r` is not a recoverable mistake.
        KeyCode::Char('R') => {
            if let Some(number) = app.selected_run().map(|r| r.number) {
                match api.rerun(&app.repo, number).await {
                    Ok(_) => {
                        app.message = format!("re-queued run #{number}");
                        refresh_runs(app, api).await;
                        reload_detail(app, api).await;
                    }
                    Err(e) => app.message = format!("rerun failed: {e}"),
                }
            }
        }

        KeyCode::Char('C') => {
            if let Some(number) = app.selected_run().map(|r| r.number) {
                match api.cancel_run(&app.repo, number).await {
                    Ok(_) => {
                        app.message = format!("cancelled run #{number}");
                        refresh_runs(app, api).await;
                        reload_detail(app, api).await;
                    }
                    Err(e) => app.message = format!("cancel failed: {e}"),
                }
            }
        }

        _ => {}
    }
    Ok(false)
}

async fn refresh_runs(app: &mut App, api: &Client) {
    match api.runs(&app.repo, Some(app.limit)).await {
        Ok(res) => {
            // Keep the cursor on the run it was on, not on the position it
            // held: a new run arriving at the top would otherwise shift the
            // selection out from under whoever is watching one.
            let selected = app.selected_run().map(|r| r.number);
            app.runs = res.data.runs;
            if let Some(number) = selected
                && let Some(index) = app.runs.iter().position(|r| r.number == number)
            {
                app.run_cursor = index;
            } else {
                app.run_cursor = app.run_cursor.min(app.runs.len().saturating_sub(1));
            }
            app.last_refresh = Some(Instant::now());
            app.message.clear();
        }
        Err(e) => app.message = format!("{e}"),
    }
}

async fn reload_detail(app: &mut App, api: &Client) {
    let Some(number) = app.selected_run().map(|r| r.number) else {
        app.detail = None;
        return;
    };
    match api.run(&app.repo, number).await {
        Ok(res) => {
            let job_id = app.selected_job().map(|j| j.id);
            app.detail = Some(res.data);
            if let Some(id) = job_id
                && let Some(index) = app
                    .detail
                    .as_ref()
                    .and_then(|d| d.jobs.iter().position(|j| j.id == id))
            {
                app.job_cursor = index;
            } else {
                app.job_cursor = 0;
            }
        }
        Err(e) => app.message = format!("{e}"),
    }
}

async fn open_logs(app: &mut App, api: &Client) {
    let Some((job, placeholder)) = app.selected_job().map(|j| (j.id, j.placeholder)) else {
        return;
    };
    match api.logs(&app.repo, job, None).await {
        Ok(res) => {
            app.logs = Some(res.data);
            app.log_scroll = 0;
            app.focus = Focus::Logs;
        }
        // A dynamic matrix's placeholder is deleted when its legs replace
        // it; the next refresh shows them, so point there.
        Err(e) if placeholder && ApiFailure::of(&e).is_some_and(|f| f.status.as_u16() == 404) => {
            app.message = "that matrix job has expanded into its legs — r to refresh".into();
            reload_detail(app, api).await;
        }
        Err(e) => app.message = format!("{e}"),
    }
}

// ── drawing ──────────────────────────────────────────────────────────

fn draw(frame: &mut Frame, app: &mut App) {
    let [header, body, footer] = Layout::vertical([
        Constraint::Length(1),
        Constraint::Min(3),
        Constraint::Length(1),
    ])
    .areas(frame.area());

    draw_header(frame, app, header);

    if app.focus == Focus::Logs && app.logs.is_some() {
        draw_logs(frame, app, body);
    } else {
        let [left, right] =
            Layout::horizontal([Constraint::Percentage(45), Constraint::Min(20)]).areas(body);
        draw_runs(frame, app, left);
        draw_detail(frame, app, right);
    }

    draw_footer(frame, app, footer);
}

fn draw_header(frame: &mut Frame, app: &App, area: Rect) {
    let refreshed = app
        .last_refresh
        .map(|t| format!("updated {}s ago", t.elapsed().as_secs()))
        .unwrap_or_else(|| "loading".into());

    let line = Line::from(vec![
        Span::styled(
            format!(" {} ", app.repo),
            Style::default().add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            format!("· {refreshed}"),
            Style::default().fg(Color::DarkGray),
        ),
        Span::raw("  "),
        Span::styled(app.message.clone(), Style::default().fg(Color::Yellow)),
    ]);
    frame.render_widget(Paragraph::new(line), area);
}

fn draw_runs(frame: &mut Frame, app: &mut App, area: Rect) {
    let items: Vec<ListItem> = app
        .runs
        .iter()
        .map(|run| {
            let state = run.display_status();
            ListItem::new(Line::from(vec![
                Span::styled(
                    format!("{} ", status_mark(state)),
                    Style::default().fg(tone_color(state)),
                ),
                Span::styled(
                    format!("#{:<5}", run.number),
                    Style::default().add_modifier(Modifier::BOLD),
                ),
                Span::raw(format!(
                    "{:<20} ",
                    crate::output::truncate(run.workflow_label(), 20)
                )),
                Span::styled(
                    format!(
                        "{:<14}",
                        crate::output::truncate(run.short_ref().unwrap_or("—"), 14)
                    ),
                    Style::default().fg(Color::Cyan),
                ),
                Span::styled(
                    ago(run.inserted_at.as_deref().or(run.started_at.as_deref())),
                    Style::default().fg(Color::DarkGray),
                ),
            ]))
        })
        .collect();

    let mut state = ListState::default();
    state.select(if app.runs.is_empty() {
        None
    } else {
        Some(app.run_cursor)
    });

    let list = List::new(items)
        .block(pane("Runs", app.focus == Focus::Runs))
        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
    frame.render_stateful_widget(list, area, &mut state);
}

fn draw_detail(frame: &mut Frame, app: &mut App, area: Rect) {
    let Some(detail) = &app.detail else {
        frame.render_widget(
            Paragraph::new("Select a run and press Enter.")
                .block(pane("Jobs", app.focus == Focus::Jobs)),
            area,
        );
        return;
    };

    let mut items: Vec<ListItem> = Vec::new();
    if detail.run.awaiting_approval {
        let lines = vec![Line::styled(
            match detail.run.pull_request_number {
                Some(pr) => format!("⚑ from a fork — waiting for approval on pull request #{pr}"),
                None => "⚑ from a fork — waiting for approval".to_string(),
            },
            Style::default().fg(Color::Yellow),
        )];
        items.push(ListItem::new(lines));
    }
    // The notice is a list item too, so the cursor skips past it.
    let offset = items.len();

    for job in &detail.jobs {
        let state = job.display_status();
        let took = duration(job.started_at.as_deref(), job.completed_at.as_deref())
            .unwrap_or_else(|| "—".into());
        let mut meta = format!("  {} · {took}", status_label(state));
        if job.attempt > 1 {
            meta.push_str(&format!(" · attempt {}", job.attempt));
        }
        let mut lines = vec![Line::from(vec![
            Span::styled(
                format!("{} ", status_mark(state)),
                Style::default().fg(tone_color(state)),
            ),
            Span::styled(job.label(), Style::default().add_modifier(Modifier::BOLD)),
            Span::styled(meta, Style::default().fg(Color::DarkGray)),
        ])];

        // Steps are shown only for the job under the cursor: every job's
        // steps at once is a wall, and the one you're looking at is the one
        // whose failing step you want to find. So is its reason.
        if detail.jobs.get(app.job_cursor).map(|j| j.id) == Some(job.id) {
            if let Some(reason) = job.reason() {
                lines.push(Line::styled(
                    format!("    {reason}"),
                    Style::default().fg(Color::DarkGray),
                ));
            }
            for step in &job.steps {
                let state = effective_status(&step.status, step.conclusion.as_deref());
                lines.push(Line::from(vec![
                    Span::raw("    "),
                    Span::styled(
                        format!("{} ", status_mark(state)),
                        Style::default().fg(tone_color(state)),
                    ),
                    Span::raw(step.name.clone().unwrap_or_else(|| "step".into())),
                ]));
            }
        }
        items.push(ListItem::new(lines));
    }

    let mut state = ListState::default();
    state.select(if detail.jobs.is_empty() {
        None
    } else {
        Some(app.job_cursor + offset)
    });

    let title = format!(
        "Run #{} · {}",
        detail.run.number,
        if detail.can_manage {
            "R re-run · C cancel"
        } else {
            "read-only"
        }
    );
    let list = List::new(items)
        .block(pane(&title, app.focus == Focus::Jobs))
        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
    frame.render_stateful_widget(list, area, &mut state);
}

fn draw_logs(frame: &mut Frame, app: &mut App, area: Rect) {
    let logs = app.logs.as_ref().expect("logs pane without logs");

    let mut lines: Vec<Line> = Vec::new();
    for step in &logs.steps {
        let state = effective_status(&step.step.status, step.step.conclusion.as_deref());
        lines.push(Line::from(vec![
            Span::styled(
                format!("{} ", status_mark(state)),
                Style::default().fg(tone_color(state)),
            ),
            Span::styled(
                step.step.name.clone().unwrap_or_else(|| "step".into()),
                Style::default().add_modifier(Modifier::BOLD),
            ),
        ]));
        for line in &step.lines {
            lines.push(Line::raw(line.body.clone()));
        }
        lines.push(Line::raw(""));
    }
    if logs.truncated {
        lines.push(Line::styled(
            "(truncated)",
            Style::default().fg(Color::DarkGray),
        ));
    }

    // Clamp here rather than on the keypress: only the draw knows how tall
    // the pane is, so this is where "past the end" can be defined.
    let height = area.height.saturating_sub(2);
    let max_scroll = (lines.len() as u16).saturating_sub(height);
    app.log_scroll = app.log_scroll.min(max_scroll);

    let title = format!("Logs · {} · Esc to go back", logs.job.label());
    frame.render_widget(
        Paragraph::new(lines)
            .block(pane(&title, true))
            .scroll((app.log_scroll, 0)),
        area,
    );
}

fn draw_footer(frame: &mut Frame, app: &App, area: Rect) {
    let keys = match app.focus {
        Focus::Logs => "j/k scroll · Home top · Esc back · q quit",
        _ => "j/k move · Enter open · l logs · Tab pane · r refresh · R re-run · C cancel · q quit",
    };
    frame.render_widget(
        Paragraph::new(Line::styled(
            format!(" {keys}"),
            Style::default().fg(Color::DarkGray),
        ))
        .wrap(Wrap { trim: true }),
        area,
    );
}

fn pane(title: &str, focused: bool) -> Block<'_> {
    Block::default()
        .borders(Borders::ALL)
        .title(format!(" {title} "))
        .border_style(if focused {
            Style::default().fg(Color::Cyan)
        } else {
            Style::default().fg(Color::DarkGray)
        })
}

/// The terminal's take on [`crate::output::status_tone`], so the two never
/// disagree about what's busy and what's bad.
fn tone_color(status: &str) -> Color {
    use crate::output::{Tone, status_tone};
    match status_tone(status) {
        Tone::Good => Color::Green,
        Tone::Bad => Color::Red,
        Tone::Busy | Tone::Warn => Color::Yellow,
        Tone::Accent => Color::Cyan,
        Tone::Muted | Tone::Plain => Color::DarkGray,
    }
}