use std::{
io::{self, Stdout, Write},
time::Duration,
};
use crossterm::{
cursor::{Hide, MoveTo, Show},
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
execute, queue,
style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor},
terminal::{
Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
enable_raw_mode, size,
},
};
use miette::{IntoDiagnostic, Result};
use tokio::sync::mpsc::UnboundedReceiver;
use bestool_alertd::doctor::{
check::{Check, CheckOutcome, CheckStatus},
progress::DoctorEvent,
};
use super::{SweepSource, order};
const SPINNER_FRAMES: &[&str] = &[
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
];
const TICK: Duration = Duration::from_millis(80);
#[derive(Clone)]
enum RowState {
Running,
Completed(CheckOutcome),
}
struct TuiRow {
name: String,
state: RowState,
}
pub struct TuiOutcome {
pub results: Vec<CheckOutcome>,
pub interrupted: bool,
}
#[derive(Default)]
struct Scroll {
offset: usize,
viewport: usize,
max: usize,
}
impl Scroll {
fn by(&mut self, delta: i64) {
let next = (self.offset as i64).saturating_add(delta).clamp(0, self.max as i64);
self.offset = next as usize;
}
}
#[derive(Clone, Debug)]
struct Segment {
text: String,
style: SegStyle,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SegStyle {
Plain,
Dim,
BoldFg(Color),
}
type StyledLine = Vec<Segment>;
fn plain<S: Into<String>>(s: S) -> Segment {
Segment {
text: s.into(),
style: SegStyle::Plain,
}
}
fn dim<S: Into<String>>(s: S) -> Segment {
Segment {
text: s.into(),
style: SegStyle::Dim,
}
}
fn bold_fg<S: Into<String>>(s: S, c: Color) -> Segment {
Segment {
text: s.into(),
style: SegStyle::BoldFg(c),
}
}
struct TerminalGuard {
stdout: Stdout,
}
impl TerminalGuard {
fn new() -> io::Result<Self> {
let mut stdout = io::stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen, Hide)?;
Ok(Self { stdout })
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = execute!(self.stdout, Show, LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
pub fn run_tui(
source: SweepSource,
mut progress_rx: UnboundedReceiver<DoctorEvent>,
) -> Result<TuiOutcome> {
let mut rows: Vec<TuiRow> = Vec::new();
let mut spinner = 0usize;
let interrupted;
let mut scroll = Scroll::default();
let mut guard = TerminalGuard::new().into_diagnostic()?;
loop {
drain_progress(&mut progress_rx, &mut rows);
let finalising = !rows.is_empty() && all_completed(&rows);
draw(
&mut guard.stdout,
&rows,
rows.len(),
&source,
spinner,
finalising,
&mut scroll,
)
.into_diagnostic()?;
if progress_rx.is_closed() && progress_rx.is_empty() {
interrupted = !finalising;
break;
}
if poll_input(TICK, &mut scroll).into_diagnostic()? {
interrupted = true;
break;
}
spinner = (spinner + 1) % SPINNER_FRAMES.len();
}
drop(guard);
let results: Vec<CheckOutcome> = rows
.into_iter()
.filter_map(|row| match row.state {
RowState::Completed(outcome) => Some(outcome),
RowState::Running => None,
})
.collect();
Ok(TuiOutcome {
results,
interrupted,
})
}
fn drain_progress(rx: &mut UnboundedReceiver<DoctorEvent>, rows: &mut Vec<TuiRow>) {
while let Ok(evt) = rx.try_recv() {
match evt {
DoctorEvent::Planned(checks) => {
*rows = checks
.into_iter()
.map(|name| TuiRow {
name,
state: RowState::Running,
})
.collect();
}
DoctorEvent::Completed(outcome) => {
let name = outcome.row_id();
match rows.iter_mut().find(|r| r.name == name) {
Some(row) => row.state = RowState::Completed(outcome),
None => rows.push(TuiRow {
name,
state: RowState::Completed(outcome),
}),
}
}
}
}
}
fn all_completed(rows: &[TuiRow]) -> bool {
rows.iter().all(|r| matches!(r.state, RowState::Completed(_)))
}
fn poll_input(timeout: Duration, scroll: &mut Scroll) -> io::Result<bool> {
if !event::poll(timeout)? {
return Ok(false);
}
while event::poll(Duration::ZERO)? {
if let Event::Key(KeyEvent {
code, modifiers, ..
}) = event::read()?
{
let ctrl_c =
matches!(code, KeyCode::Char('c')) && modifiers.contains(KeyModifiers::CONTROL);
if ctrl_c || matches!(code, KeyCode::Char('q')) {
return Ok(true);
}
let page = scroll.viewport.max(1) as i64;
match code {
KeyCode::Up | KeyCode::Char('k') => scroll.by(-1),
KeyCode::Down | KeyCode::Char('j') => scroll.by(1),
KeyCode::PageUp => scroll.by(-page),
KeyCode::PageDown | KeyCode::Char(' ') => scroll.by(page),
KeyCode::Home | KeyCode::Char('g') => scroll.by(i64::MIN),
KeyCode::End | KeyCode::Char('G') => scroll.by(i64::MAX),
_ => {}
}
}
}
Ok(false)
}
fn draw(
out: &mut Stdout,
rows: &[TuiRow],
total: usize,
source: &SweepSource,
spinner: usize,
finalising: bool,
scroll: &mut Scroll,
) -> io::Result<()> {
let term_rows = size().map(|(_, r)| r as usize).unwrap_or(24).max(2);
let body = build_rows(rows, spinner);
let viewport = term_rows.saturating_sub(2);
scroll.viewport = viewport;
scroll.max = body.len().saturating_sub(viewport);
scroll.offset = scroll.offset.min(scroll.max);
let end = (scroll.offset + viewport).min(body.len());
let visible = &body[scroll.offset..end];
queue!(out, MoveTo(0, 0))?;
write_line(out, &header_line(source))?;
queue!(out, Clear(ClearType::UntilNewLine))?;
out.write_all(b"\r\n")?;
for _ in 0..viewport.saturating_sub(visible.len()) {
queue!(out, Clear(ClearType::UntilNewLine))?;
out.write_all(b"\r\n")?;
}
for line in visible {
write_line(out, line)?;
queue!(out, Clear(ClearType::UntilNewLine))?;
out.write_all(b"\r\n")?;
}
write_line(out, &footer_line(rows, total, spinner, finalising, scroll))?;
queue!(out, Clear(ClearType::UntilNewLine))?;
out.flush()
}
fn write_line(out: &mut Stdout, line: &StyledLine) -> io::Result<()> {
for seg in line {
match seg.style {
SegStyle::Plain => out.write_all(seg.text.as_bytes())?,
SegStyle::Dim => {
queue!(out, SetAttribute(Attribute::Dim))?;
out.write_all(seg.text.as_bytes())?;
queue!(out, SetAttribute(Attribute::Reset))?;
}
SegStyle::BoldFg(c) => {
queue!(out, SetForegroundColor(c), SetAttribute(Attribute::Bold))?;
out.write_all(seg.text.as_bytes())?;
queue!(out, SetAttribute(Attribute::Reset), ResetColor)?;
}
}
}
Ok(())
}
fn header_line(source: &SweepSource) -> StyledLine {
let text = match source {
SweepSource::Local => "Source: local".to_string(),
SweepSource::DaemonStreamed => "Source: alertd daemon (just now, on demand)".to_string(),
SweepSource::DaemonCached { computed_at } => {
let age = super::render::humanise_age_since(*computed_at);
format!("Source: alertd daemon (computed {age} ago, at {computed_at})")
}
};
vec![dim(text)]
}
fn build_rows(rows: &[TuiRow], spinner: usize) -> Vec<StyledLine> {
let name_width = rows.iter().map(|r| r.name.len()).max().unwrap_or(0);
let mut ordered: Vec<&TuiRow> = rows
.iter()
.filter(|row| match &row.state {
RowState::Running => true,
RowState::Completed(o) => !matches!(o.check.status, CheckStatus::Skip(_)),
})
.collect();
ordered.sort_by(|a, b| sort_key(a).cmp(&sort_key(b)).then_with(|| a.name.cmp(&b.name)));
let mut lines = Vec::with_capacity(ordered.len());
for row in ordered {
match &row.state {
RowState::Running => lines.push(row_line_running(&row.name, name_width, spinner)),
RowState::Completed(check) => {
lines.push(row_line_completed(check, &row.name, name_width));
if let Some(reason) = reason_for(&check.check) {
lines.push(reason_line(reason, name_width));
}
}
}
}
lines
}
fn sort_key(row: &TuiRow) -> u8 {
match &row.state {
RowState::Running => 0,
RowState::Completed(o) => 1 + order::severity_key(&o.check.status),
}
}
fn row_line_running(name: &str, name_width: usize, spinner: usize) -> StyledLine {
let frame = SPINNER_FRAMES[spinner % SPINNER_FRAMES.len()];
let pad = " ".repeat(name_width.saturating_sub(name.len()));
vec![
plain(" "),
dim(format!("{frame:<4}")),
plain(" "),
plain(name.to_string()),
plain(pad),
plain(" "),
dim("…"),
]
}
fn row_line_completed(outcome: &CheckOutcome, name: &str, name_width: usize) -> StyledLine {
let check = &outcome.check;
let (tag, color) = tag_for(check);
let pad = " ".repeat(name_width.saturating_sub(name.len()));
vec![
plain(" "),
bold_fg(format!("{tag:<4}"), color),
plain(" "),
plain(name),
plain(pad),
plain(" "),
plain(check.summary.clone()),
]
}
fn reason_line(reason: &str, name_width: usize) -> StyledLine {
let lead = " ".repeat(10 + name_width + 5);
vec![plain(lead), dim(reason.to_string())]
}
fn reason_for(check: &Check) -> Option<&str> {
match &check.status {
CheckStatus::Pass => None,
CheckStatus::Skip(r)
| CheckStatus::Warning(r)
| CheckStatus::Fail(r)
| CheckStatus::Broken(r) => Some(r.as_str()),
}
}
fn tag_for(check: &Check) -> (&'static str, Color) {
match &check.status {
CheckStatus::Pass => ("PASS", Color::Green),
CheckStatus::Skip(_) => ("SKIP", Color::DarkGrey),
CheckStatus::Warning(_) => ("WARN", Color::Yellow),
CheckStatus::Broken(_) => ("BRKN", Color::Magenta),
CheckStatus::Fail(_) => ("FAIL", Color::Red),
}
}
fn footer_line(
rows: &[TuiRow],
total: usize,
spinner: usize,
finalising: bool,
scroll: &Scroll,
) -> StyledLine {
let completed = rows
.iter()
.filter(|r| matches!(r.state, RowState::Completed(_)))
.count();
let frame = SPINNER_FRAMES[spinner % SPINNER_FRAMES.len()];
let status = if finalising {
format!("{frame} finalising… ({completed} / {total} checks done)")
} else {
format!("{frame} {completed} / {total} complete")
};
let mut line = vec![dim(status)];
if scroll.max > 0 {
let above = scroll.offset;
let below = scroll.max - scroll.offset;
line.push(dim(format!(
" ↑/↓ scroll ({above} above, {below} below)"
)));
}
line
}
#[cfg(test)]
mod tests {
use bestool_alertd::doctor::subject::{ApplicationRef, Subject};
use super::*;
fn done(check: Check) -> TuiRow {
TuiRow {
name: Subject::Machine.qualify(check.name),
state: RowState::Completed(CheckOutcome {
subject: Subject::Machine,
check,
on_wire: true,
}),
}
}
fn running(name: &str) -> TuiRow {
TuiRow {
name: Subject::Machine.qualify(name),
state: RowState::Running,
}
}
fn line_text(line: &StyledLine) -> String {
line.iter().map(|s| s.text.as_str()).collect()
}
fn drain(rows: &mut Vec<TuiRow>, events: Vec<DoctorEvent>) {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
for e in events {
tx.send(e).unwrap();
}
drop(tx);
drain_progress(&mut rx, rows);
}
#[test]
fn the_plan_seeds_every_check_pending() {
let mut rows = Vec::new();
drain(
&mut rows,
vec![DoctorEvent::Planned(vec![
"machine:memory".into(),
"tamanu-central:migrations".into(),
])],
);
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|r| matches!(r.state, RowState::Running)));
}
#[test]
fn a_result_fills_its_planned_row_rather_than_adding_one() {
let mut rows = Vec::new();
drain(
&mut rows,
vec![
DoctorEvent::Planned(vec!["machine:memory".into()]),
DoctorEvent::Completed(CheckOutcome {
subject: Subject::Machine,
check: Check::pass("memory", "ok"),
on_wire: true,
}),
],
);
assert_eq!(rows.len(), 1);
assert!(matches!(rows[0].state, RowState::Completed(_)));
}
#[test]
fn two_clusters_of_one_name_are_two_rows() {
let outcome = |port: u16| CheckOutcome {
subject: Subject::Application(ApplicationRef::local_postgres(port)),
check: Check::pass("connect", "ok"),
on_wire: true,
};
let mut rows = Vec::new();
drain(
&mut rows,
vec![
DoctorEvent::Planned(vec![
"postgres-5432:connect".into(),
"postgres-5433:connect".into(),
]),
DoctorEvent::Completed(outcome(5432)),
DoctorEvent::Completed(outcome(5433)),
],
);
assert_eq!(rows.len(), 2, "one row per cluster");
assert!(rows.iter().all(|r| matches!(r.state, RowState::Completed(_))));
}
#[test]
fn a_result_outside_the_plan_is_kept() {
let mut rows = Vec::new();
drain(
&mut rows,
vec![DoctorEvent::Completed(CheckOutcome {
subject: Subject::Machine,
check: Check::pass("memory", "ok"),
on_wire: true,
})],
);
assert_eq!(rows.len(), 1);
}
#[test]
fn build_rows_orders_running_then_pass_warn_broken_fail() {
let rows = vec![
done(Check::fail("z-fail", "bad", "r")),
done(Check::pass("a-pass", "ok")),
done(Check::warning("m-warn", "deg", "r")),
running("k-run"),
done(Check::broken("c-broken", "broke", "r")),
];
let lines = build_rows(&rows, 0);
let joined: Vec<String> = lines.iter().map(line_text).collect();
let positions = |needle: &str| {
joined
.iter()
.position(|s| s.contains(needle))
.unwrap_or_else(|| panic!("missing {needle}"))
};
assert!(positions("k-run") < positions("a-pass"));
assert!(positions("a-pass") < positions("m-warn"));
assert!(positions("m-warn") < positions("c-broken"));
assert!(positions("c-broken") < positions("z-fail"));
}
#[test]
fn build_rows_drops_completed_skip_but_keeps_pass_and_running() {
let rows = vec![
done(Check::pass("a-pass", "ok")),
done(Check::skip("b-skip", "n/a", "r")),
done(Check::warning("c-warn", "deg", "r")),
running("d-run"),
];
let lines = build_rows(&rows, 0);
let joined: Vec<String> = lines.iter().map(line_text).collect();
assert!(joined.iter().any(|s| s.contains("d-run")));
assert!(joined.iter().any(|s| s.contains("c-warn")));
assert!(joined.iter().any(|s| s.contains("a-pass")));
assert!(!joined.iter().any(|s| s.contains("b-skip")));
}
#[test]
fn footer_counts_completed_against_total() {
let rows = vec![
done(Check::pass("a", "ok")),
done(Check::warning("b", "deg", "r")),
running("c"),
];
let line = footer_line(&rows, 3, 0, false, &Scroll::default());
assert!(line_text(&line).contains("2 / 3 complete"));
}
#[test]
fn footer_shows_finalising_when_all_done() {
let rows = vec![done(Check::pass("a", "ok"))];
let line = footer_line(&rows, 1, 0, true, &Scroll::default());
assert!(line_text(&line).contains("finalising"));
}
#[test]
fn footer_shows_scroll_hint_only_when_scrollable() {
let rows = vec![running("a")];
let none = footer_line(&rows, 1, 0, false, &Scroll::default());
assert!(!line_text(&none).contains("scroll"));
let scroll = Scroll {
offset: 2,
viewport: 5,
max: 7,
};
let some = footer_line(&rows, 1, 0, false, &scroll);
let text = line_text(&some);
assert!(text.contains("2 above"));
assert!(text.contains("5 below"));
}
#[test]
fn scroll_by_clamps_to_range() {
let mut s = Scroll {
offset: 0,
viewport: 5,
max: 4,
};
s.by(-1);
assert_eq!(s.offset, 0);
s.by(2);
assert_eq!(s.offset, 2);
s.by(100);
assert_eq!(s.offset, 4);
s.by(i64::MIN);
assert_eq!(s.offset, 0);
}
}