//! 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,
}
}
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
18.6 KiB · text
History
52481e7