use std::fmt::Write as _;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Borders, Cell, Clear, Paragraph, Row, Sparkline, Table, TableState, Wrap,
};
use runnerd_core::api::{
AdmissionPolicy, Advice, Job, JobSummary, ProfileSizing, ProfileStatus, RepositoryStatus,
Sample, Sizing, SlotStatus, Status, Step,
};
use runnerd_core::config::ByteSize;
use unicode_width::UnicodeWidthStr;
use super::app::{App, Connection, JobView, LatestRow, Outcome, Pane, Removal, View};
use crate::human::{self, Tone, bytes, cores, duration};
use crate::watch;
const PROFILES: [u8; 6] = [194, 224, 189, 229, 153, 223];
const MEMORY: u8 = 114;
const CPU: u8 = 147;
const WAIT: u8 = 221;
#[derive(Debug, Clone, Copy)]
pub(crate) struct Theme {
pub(crate) colour: bool,
}
impl Theme {
fn fg(self, colour: Color) -> Style {
if self.colour {
Style::new().fg(colour)
} else {
Style::new()
}
}
fn profile(self, index: Option<usize>) -> Style {
index.map_or_else(
|| self.dim(),
|index| self.fg(Color::Indexed(PROFILES[index % PROFILES.len()])),
)
}
fn dim(self) -> Style {
if self.colour {
Style::new().fg(Color::DarkGray)
} else {
Style::new().add_modifier(Modifier::DIM)
}
}
fn memory(self) -> Style {
self.fg(Color::Indexed(MEMORY))
}
fn cpu(self) -> Style {
self.fg(Color::Indexed(CPU))
}
fn wait(self) -> Style {
self.fg(Color::Indexed(WAIT))
}
fn tone(self, tone: Tone) -> Style {
match tone {
Tone::Plain => Style::new(),
Tone::Bold => Style::new().add_modifier(Modifier::BOLD),
Tone::Dim => self.dim(),
Tone::Good => self.memory(),
Tone::Bad => self.fg(Color::LightRed).add_modifier(Modifier::BOLD),
Tone::Busy => self.cpu(),
}
}
}
fn bold() -> Style {
Style::new().add_modifier(Modifier::BOLD)
}
fn selected() -> Style {
Style::new().add_modifier(Modifier::REVERSED)
}
pub(crate) fn draw(frame: &mut Frame<'_>, app: &App, now: u64, theme: Theme) {
let [top, body, bottom] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(frame.area());
header(frame, top, app, theme);
match app.view {
View::Overview => overview(frame, body, app, now, theme),
View::History => history(frame, body, app, now, theme),
View::Records => records(frame, body, app, now, theme),
View::Sizing => sizing(frame, body, app, theme),
View::Job => {
if let Some(open) = &app.job {
job(frame, body, app, open, now, theme);
}
}
}
footer(frame, bottom, app, theme);
if app.help {
help(frame, body);
}
}
fn header(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) {
let mut spans = vec![
Span::styled(" runnerd ", bold()),
Span::styled(format!("{} ", app.live.status.version), theme.dim()),
];
for (key, name, view) in [
("1", "Overview", View::Overview),
("2", "History", View::History),
("3", "Records", View::Records),
("4", "Sizing", View::Sizing),
] {
let style = if app.view == view {
selected()
} else {
Style::new()
};
spans.push(Span::styled(format!(" {key} {name} "), style));
}
if let Some(open) = &app.job {
spans.push(Span::styled(
format!(" job {} ", open.job.summary.id),
selected(),
));
}
frame.render_widget(Line::from(spans), area);
let mut right = Vec::new();
if app.live.status.shutting_down {
right.push(Span::styled("shutting down ", theme.wait()));
}
match &app.connection {
Connection::Live => right.push(Span::styled("โ live ", theme.memory())),
Connection::Reconnecting(why) => right.push(Span::styled(
format!("โก reconnecting: {why} "),
theme.wait().add_modifier(Modifier::BOLD),
)),
}
frame.render_widget(Line::from(right).alignment(Alignment::Right), area);
}
fn footer(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) {
let line = if let Some(refused) = &app.confirm {
let ask = if refused.jobs == 1 {
" y cancels the job's run and pauses, any other key keeps it running: ".to_owned()
} else {
format!(
" y cancels the runs of {} jobs and pauses, any other key keeps them running: ",
refused.jobs
)
};
let rest = usize::from(area.width).saturating_sub(ask.width());
Line::from(vec![
Span::styled(ask, bold()),
Span::styled(fit(&refused.message, rest), theme.wait()),
])
} else if let Some(removal) = app.removal {
let ask = match removal {
Removal::Job(job) => {
format!(" y removes the record of job {job}, any other key keeps it")
}
Removal::All => " y removes every record, any other key keeps them".to_owned(),
};
Line::styled(ask, bold())
} else if let Some(resizing) = &app.resize {
Line::styled(
format!(
" y writes memory = \"{}\", slots = {} for {} into the daemon's config, any other key keeps it",
ByteSize(resizing.resize.memory),
resizing.resize.slots,
resizing.profile
),
bold(),
)
} else if let Some(flash) = &app.flash {
let style = if flash.outcome == Outcome::Failed {
theme.tone(Tone::Bad)
} else {
theme.memory()
};
Line::styled(format!(" {}", flash.text), style)
} else {
let keys = match app.view {
View::Overview => {
"tab pane j/k move enter open p pause r resume R reseed ? help q quit"
}
View::History => {
"j/k move enter open c outcome s repository ] next [ back ? help q quit"
}
View::Records => "j/k move enter open d remove D remove all ? help q quit",
View::Sizing => "j/k move a apply the advice ? help q quit",
View::Job => {
"j/k scroll G follow p pause r resume R reseed esc back ? help q quit"
}
};
Line::styled(format!(" {keys}"), theme.dim())
};
frame.render_widget(line, area);
}
fn help(frame: &mut Frame<'_>, area: Rect) {
const KEYS: [(&str, &str); 20] = [
("1 o", "overview"),
("2 h", "history"),
("3", "records of failed jobs"),
("4", "sizing: what each profile's jobs use"),
("tab", "next pane of the overview"),
("j k โ โ", "move, or scroll the log"),
("PgDn PgUp", "move by ten"),
("enter", "open the job"),
("esc", "back from a job"),
("G g", "follow the log, or go to its start"),
("p r", "pause the repository, or resume it"),
("P U", "pause every repository, or resume them"),
("R", "reseed the profile's snapshot"),
("c s", "history: cycle the outcome or repository filter"),
("] [", "history: next and previous page"),
("d D", "records: remove the record, or every record"),
("a", "sizing: write the advice into the config"),
("?", "this help; any key closes it"),
("ctrl-z", "suspend; the shell's fg brings it back"),
("q ctrl-c", "quit"),
];
let width = area.width.min(60);
let height = area
.height
.min(u16::try_from(KEYS.len()).unwrap_or(u16::MAX) + 2);
let popup = Rect {
x: area.x + (area.width - width) / 2,
y: area.y + (area.height - height) / 2,
width,
height,
};
let lines: Vec<Line<'_>> = KEYS
.iter()
.map(|(key, what)| {
Line::from(vec![
Span::styled(format!(" {key:<10}"), bold()),
Span::raw(*what),
])
})
.collect();
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(lines).block(Block::bordered().title(" Keys ")),
popup,
);
}
fn heading(frame: &mut Frame<'_>, area: Rect, title: &str, note: String, theme: Theme) {
frame.render_widget(Line::styled(title.to_owned(), bold()), area);
frame.render_widget(
Line::styled(note, theme.dim()).alignment(Alignment::Right),
area,
);
}
fn overview(frame: &mut Frame<'_>, area: Rect, app: &App, now: u64, theme: Theme) {
let slots = app.slots().count();
let columns = usize::from(area.width / 30).clamp(1, slots.max(1));
let cards = u16::try_from(slots.div_ceil(columns) * 7 + 1).unwrap_or(u16::MAX);
let lines = u16::try_from(slots + 1).unwrap_or(u16::MAX);
let rest = area.height.saturating_sub(3);
let as_cards = rest >= cards + 8;
let slots_height = if as_cards { cards } else { lines };
let charts = rest.saturating_sub(slots_height) >= 7 + 6;
let [budget_area, slots_area, charts_area, lists_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(slots_height),
Constraint::Length(if charts { 7 } else { 0 }),
Constraint::Min(0),
])
.areas(area);
budget(frame, budget_area, app, theme);
if as_cards {
slot_cards(frame, slots_area, app, columns, now, theme);
} else {
slot_lines(frame, slots_area, app, now, theme);
}
if charts {
slice(frame, charts_area, app, theme);
}
let [repositories_area, latest_area] = if lists_area.width >= 70 {
Layout::horizontal([Constraint::Percentage(45), Constraint::Percentage(55)])
.areas(lists_area)
} else {
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(lists_area)
};
repositories(frame, repositories_area, app, theme);
latest(frame, latest_area, app, now, theme);
}
fn scale(part: u64, whole: u64, width: u16) -> u16 {
let cells = u128::from(part) * u128::from(width) / u128::from(whole.max(1));
u16::try_from(cells).unwrap_or(width).min(width)
}
fn budget_note(app: &App) -> String {
let budget = &app.live.status.budget;
let mut note = match budget.memory_max {
Some(max) => format!(
"{} of {} reserved in {}",
bytes(budget.memory_in_use),
bytes(max),
budget.slice
),
None => format!(
"{} reserved in {}, no MemoryMax",
bytes(budget.memory_in_use),
budget.slice
),
};
if let Some(by) = budget.admission_note() {
let _ = write!(note, " {by}");
}
if let Some(host) = &app.live.host {
let _ = write!(note, " ยท {} in use, sampled", bytes(host.memory));
}
note
}
fn budget(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) {
let status = &app.live.status;
let budget = &status.budget;
let mut note = budget_note(app);
let [title, bar, labels] = Layout::vertical([Constraint::Length(1); 3]).areas(area);
let blocks: Vec<_> = app
.slots()
.filter_map(|(index, profile, slot)| {
let memory = match budget.admission {
AdmissionPolicy::Caps => Some(profile.memory),
AdmissionPolicy::Measured => slot.runner.as_ref().map(|runner| runner.reserved),
}?;
Some((index, profile, slot, memory))
})
.collect();
let caps: u64 = blocks.iter().map(|(_, _, _, memory)| memory).sum();
let whole = budget.memory_max.unwrap_or(caps).max(caps);
let mut cells = Vec::new();
let mut names = Vec::new();
let mut drawn = 0;
for (index, profile, slot, memory) in blocks {
let width = scale(memory, whole, bar.width).max(1);
let style = theme.profile(Some(index));
let sample =
slot_job(app, profile, slot).and_then(|job| app.live.samples.get(&job.summary.id));
let used = sample.map_or(0, |sample| scale(sample.memory, memory, width));
let (text, label) = if slot.runner.is_some() {
(
format!(
"{}{}",
"โ".repeat(usize::from(used)),
"โ".repeat(usize::from(width - used))
),
sample.map_or_else(
|| format!("{} {} {}", profile.name, slot.slot, bytes(memory)),
|sample| {
format!(
"{} {} {} used",
profile.name,
slot.slot,
bytes(sample.memory)
)
},
),
)
} else {
(
"โ".repeat(usize::from(width)),
format!("{} {} idle", profile.name, slot.slot),
)
};
let mut text: String = text
.chars()
.take(usize::from(width.saturating_sub(1)))
.collect();
text.push(' ');
cells.push(Span::styled(text, style));
names.push(Span::styled(
format!("{} ", fit(&label, usize::from(width.saturating_sub(1)))),
style,
));
drawn += width;
}
for Held {
style,
label,
memory,
} in held(status, theme)
{
let width = scale(memory, whole, bar.width)
.max(2)
.min(bar.width.saturating_sub(drawn));
let room = usize::from(width.saturating_sub(1));
if label.width() > room {
let _ = write!(note, " ยท {label}");
}
if width < 2 {
continue;
}
let mut text = "โ".repeat(room);
text.push(' ');
cells.push(Span::styled(text, style));
let name = if label.width() <= room {
label
} else {
String::new()
};
names.push(Span::styled(format!("{name:room$} "), style));
drawn += width;
}
heading(frame, title, "Budget", note, theme);
let free = bar.width.saturating_sub(drawn);
cells.push(Span::styled("ยท".repeat(usize::from(free)), theme.dim()));
frame.render_widget(Line::from(cells), bar);
frame.render_widget(Line::from(names), labels);
}
struct Held {
style: Style,
label: String,
memory: u64,
}
fn held(status: &Status, theme: Theme) -> Vec<Held> {
let budget = &status.budget;
let mirror = (budget.mirror_memory > 0).then(|| Held {
style: Style::default(),
label: format!("mirror {}", bytes(budget.mirror_memory)),
memory: budget.mirror_memory,
});
let seeds = budget.seed_memory.iter().map(|seed| {
let index = status
.profiles
.iter()
.position(|profile| profile.name == seed.profile);
Held {
style: theme.profile(index),
label: format!("{} seed {}", seed.profile, bytes(seed.memory)),
memory: seed.memory,
}
});
let shells = budget.shell_memory.iter().map(|shell| Held {
style: Style::default(),
label: format!("job {} shell {}", shell.job, bytes(shell.memory)),
memory: shell.memory,
});
mirror.into_iter().chain(seeds).chain(shells).collect()
}
fn slot_job<'a>(app: &'a App, profile: &ProfileStatus, slot: &SlotStatus) -> Option<&'a Job> {
app.live.job_in(&profile.name, slot.slot)
}
fn spark(values: impl DoubleEndedIterator<Item = u64>, max: u64, width: usize) -> String {
const BARS: [char; 8] = ['โ', 'โ', 'โ', 'โ', 'โ
', 'โ', 'โ', 'โ'];
let mut bars: Vec<char> = values
.rev()
.take(width)
.map(|value| {
let level = u128::from(value) * 7 / u128::from(max.max(1));
BARS[usize::try_from(level).unwrap_or(7).min(7)]
})
.collect();
bars.reverse();
bars.into_iter().collect()
}
fn fit(text: &str, width: usize) -> String {
let mut cut = String::new();
if text.width() <= width {
cut.push_str(text);
} else if width > 0 {
for character in text.chars() {
cut.push(character);
if cut.width() + 1 > width {
cut.pop();
break;
}
}
cut.push('โฆ');
}
let used = cut.width();
cut.extend(std::iter::repeat_n(' ', width.saturating_sub(used)));
cut
}
struct Usage<'app> {
samples: Vec<&'app Sample>,
memory_cap: u64,
cpu_cap: u64,
}
impl Usage<'_> {
fn of<'app>(app: &'app App, profile: &ProfileStatus, job: &Job) -> Usage<'app> {
let runner = job.summary.runner.as_ref();
Usage {
samples: app
.series
.get(&job.summary.id)
.map(|series| series.iter().collect())
.unwrap_or_default(),
memory_cap: runner
.and_then(|runner| runner.memory_cap)
.unwrap_or(profile.memory),
cpu_cap: runner
.and_then(|runner| runner.cpu_cap)
.unwrap_or(u64::from(profile.cpus) * 1000),
}
}
fn last(&self) -> Option<&Sample> {
self.samples.last().copied()
}
fn memory(&self, width: usize) -> String {
spark(
self.samples.iter().map(|sample| sample.memory),
self.memory_cap,
width,
)
}
fn cpu(&self, width: usize) -> String {
spark(
self.samples.iter().map(|sample| sample.cpu),
self.cpu_cap,
width,
)
}
}
fn step_label(job: &Job) -> Option<(String, String)> {
watch::current_step(job)
.map(|(step, position)| (format!("{position}/{}", job.steps.len()), step.name.clone()))
}
fn slot_lines(frame: &mut Frame<'_>, area: Rect, app: &App, now: u64, theme: Theme) {
let running = app.live.running.len();
let queued = app.live.status.queued.len();
let [title, rest] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
heading(
frame,
title,
"Slots",
format!("{running} running ยท {queued} queued"),
theme,
);
let focused = app.pane == Pane::Slots;
let width = usize::from(rest.width);
let lines: Vec<Line<'_>> = app
.slots()
.enumerate()
.map(|(position, (index, profile, slot))| {
let style = theme.profile(Some(index));
let mut spans = vec![
Span::styled("โ ", style),
Span::styled(fit(&format!("{} {}", profile.name, slot.slot), 9), bold()),
];
match (slot_job(app, profile, slot), &slot.runner) {
(Some(job), _) => {
let usage = Usage::of(app, profile, job);
let (count, name) = step_label(job).unwrap_or_default();
let ran = job
.summary
.timeline
.started_at
.map_or_else(String::new, |at| duration(now.saturating_sub(at)));
let memory = usage.last().map_or_else(String::new, |s| bytes(s.memory));
let cpu = usage.last().map_or_else(String::new, |s| cores(s.cpu));
let job_width = width
.saturating_sub(11 + 7 + 16 + 9 + 7 + 6 + 6 + 6)
.max(10);
spans.extend([
Span::raw(fit(
&format!("{} {}", human::title(&job.summary), job.summary.repository),
job_width,
)),
Span::styled(format!(" {count:>5} "), theme.cpu()),
Span::raw(fit(&name, 16)),
Span::raw(format!(" {ran:>7} ")),
Span::raw(format!("{memory:>6} ")),
Span::styled(usage.memory(6), theme.memory()),
Span::raw(format!("{cpu:>5} ")),
Span::styled(usage.cpu(6), theme.cpu()),
]);
}
(None, Some(runner)) => spans.push(Span::styled(
format!("runner for {} waiting for its job", runner.repository),
theme.dim(),
)),
(None, None) => spans.push(Span::styled("idle", theme.dim())),
}
let line = Line::from(spans);
if focused && position == app.slot {
line.style(selected())
} else {
line
}
})
.collect();
frame.render_widget(Paragraph::new(lines), rest);
}
fn slot_cards(
frame: &mut Frame<'_>,
area: Rect,
app: &App,
columns: usize,
now: u64,
theme: Theme,
) {
let [title, rest] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
heading(
frame,
title,
"Slots",
format!(
"{} running ยท {} queued",
app.live.running.len(),
app.live.status.queued.len()
),
theme,
);
let slots: Vec<_> = app.slots().collect();
let rows =
Layout::vertical(vec![Constraint::Length(7); slots.len().div_ceil(columns)]).split(rest);
let cells = |row| {
Layout::horizontal(vec![
Constraint::Ratio(
1,
u32::try_from(columns).unwrap_or(1)
);
columns
])
.split(row)
};
let name_width = usize::from(cells(rest).iter().map(|cell| cell.width).min().unwrap_or(0))
.saturating_sub(2 + 1 + 3 + 1);
let longest = slots
.iter()
.map(|(_, profile, slot)| card_title(profile, slot).width())
.max()
.unwrap_or(0);
let detail = (0..2)
.find(|&detail| {
slots
.iter()
.all(|(_, profile, _)| longest + card_sizes(profile)[detail].width() <= name_width)
})
.unwrap_or(2);
for (position, (index, profile, slot)) in slots.into_iter().enumerate() {
let cell = cells(rows[position / columns])[position % columns];
let style = theme.profile(Some(index));
let focused = app.pane == Pane::Slots && position == app.slot;
let block = Block::bordered()
.border_type(if focused {
BorderType::Thick
} else {
BorderType::Plain
})
.border_style(style)
.title(Line::from(vec![
Span::styled(" โ ", style),
Span::styled(
format!(
"{} ",
fit(&card_title(profile, slot), name_width).trim_end()
),
if focused { selected() } else { bold() },
),
]))
.title_top(
Line::styled(card_sizes(profile)[detail].clone(), theme.dim()).right_aligned(),
);
let inner = block.inner(cell);
frame.render_widget(block, cell);
let lines = card(app, profile, slot, usize::from(inner.width), now, theme);
frame.render_widget(Paragraph::new(lines), inner);
}
}
fn card_title(profile: &ProfileStatus, slot: &SlotStatus) -> String {
format!("{} ยท slot {}", profile.name, slot.slot)
}
fn card_sizes(profile: &ProfileStatus) -> [String; 3] {
[
format!(" {} ยท {} CPUs ", bytes(profile.memory), profile.cpus),
format!(" {} ", bytes(profile.memory)),
String::new(),
]
}
fn card(
app: &App,
profile: &ProfileStatus,
slot: &SlotStatus,
width: usize,
now: u64,
theme: Theme,
) -> Vec<Line<'static>> {
match (slot_job(app, profile, slot), &slot.runner) {
(Some(job), _) => {
let usage = Usage::of(app, profile, job);
let (count, name) = step_label(job).unwrap_or_default();
let ran = job
.summary
.timeline
.started_at
.map_or_else(String::new, |at| duration(now.saturating_sub(at)));
let memory = usage
.last()
.map_or_else(|| "โ".to_owned(), |s| bytes(s.memory));
let cpu = usage
.last()
.map_or_else(|| "โ".to_owned(), |s| cores(s.cpu));
let half = width.saturating_sub(1) / 2;
vec![
Line::styled(fit(&job.summary.repository, width), theme.dim()),
Line::styled(fit(&human::title(&job.summary), width), bold()),
Line::from(vec![
Span::styled(
format!("{count} "),
theme.cpu().add_modifier(Modifier::BOLD),
),
Span::raw(fit(&name, width.saturating_sub(count.width() + 1))),
]),
Line::from(vec![
Span::raw(format!("{ran:<8}")),
Span::styled("mem ", theme.dim()),
Span::raw(format!("{memory:<7}")),
Span::styled("cpu ", theme.dim()),
Span::raw(cpu),
]),
Line::from(vec![
Span::styled(format!("{:<half$}", usage.memory(half)), theme.memory()),
Span::raw(" "),
Span::styled(usage.cpu(half), theme.cpu()),
]),
]
}
(None, Some(runner)) => vec![
Line::styled(
fit(
&format!("Runner for {} waiting for its job", runner.repository),
width,
),
theme.dim(),
),
Line::styled(
format!("started {}", human::ago(runner.started_at, now)),
theme.dim(),
),
],
(None, None) => vec![Line::styled("Idle", theme.dim())],
}
}
fn slice(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) {
let [memory_area, cpu_area] =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(area);
let status = &app.live.status;
let memory_max = status.budget.memory_max;
let cpus: u64 = status
.profiles
.iter()
.map(|profile| u64::from(profile.cpus) * profile.slots.len() as u64)
.sum();
Chart {
title: "Memory",
limit_label: memory_max.map_or_else(String::new, |max| format!("MemoryMax {}", bytes(max))),
values: app.host.iter().map(|sample| sample.memory).collect(),
limit: memory_max.unwrap_or(0),
unit: bytes,
style: theme.memory(),
}
.render(frame, memory_area, theme);
Chart {
title: "CPU",
limit_label: format!("{cpus} CPUs of all slots"),
values: app.host.iter().map(|sample| sample.cpu).collect(),
limit: cpus * 1000,
unit: in_cores,
style: theme.cpu(),
}
.render(frame, cpu_area, theme);
}
struct Chart {
title: &'static str,
limit_label: String,
values: Vec<u64>,
limit: u64,
unit: fn(u64) -> String,
style: Style,
}
impl Chart {
fn render(&self, frame: &mut Frame<'_>, area: Rect, theme: Theme) {
let unit = self.unit;
let peak = self.values.iter().copied().max();
let block = Block::bordered()
.border_style(theme.dim())
.title(Line::from(vec![
Span::styled(format!(" {} ", self.title), bold()),
Span::raw(
self.values
.last()
.map_or_else(String::new, |last| format!("{} ", unit(*last))),
),
]))
.title_top(Line::styled(format!(" {} ", self.limit_label), theme.dim()).right_aligned())
.title_bottom(Line::styled(
peak.map_or_else(
|| " no samples yet ".to_owned(),
|peak| format!(" sampled peak {} ", unit(peak)),
),
theme.dim(),
));
let inner = block.inner(area);
frame.render_widget(block, area);
let shown = &self.values[self.values.len().saturating_sub(usize::from(inner.width))..];
let max = if self.limit > 0 {
self.limit
} else {
peak.unwrap_or(1)
};
frame.render_widget(
Sparkline::default().data(shown).max(max).style(self.style),
inner,
);
}
}
fn in_cores(millicores: u64) -> String {
format!("{} cores", cores(millicores))
}
fn list_block(title: &str, focused: bool, theme: Theme) -> Block<'_> {
Block::new()
.borders(Borders::TOP)
.border_style(if focused { bold() } else { theme.dim() })
.title(Span::styled(
format!(" {title} "),
if focused { selected() } else { bold() },
))
}
fn repositories(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) {
let focused = app.pane == Pane::Repositories;
let repositories = &app.live.status.repositories;
let width = usize::from(area.width).saturating_sub(1);
let spans = |repository: &RepositoryStatus, profile: Option<&str>, name_width: usize| {
let mut spans = vec![
Span::styled("โ ", theme.profile(app.profile_index(&repository.profile))),
Span::styled(
fit(&repository.name, name_width).trim_end().to_owned(),
bold(),
),
];
if let Some(profile) = profile {
spans.push(Span::styled(format!(" {profile}"), theme.dim()));
}
spans.push(Span::raw(format!(" {} queued", repository.queued_jobs)));
if repository.paused {
spans.push(Span::styled(
" paused",
theme.wait().add_modifier(Modifier::BOLD),
));
}
spans
};
let with_profile = repositories.iter().all(|repository| {
let name_width = repository.name.width();
Line::from(spans(repository, Some(&repository.profile), name_width)).width() <= width
});
let lines: Vec<Line<'_>> = repositories
.iter()
.enumerate()
.map(|(position, repository)| {
let profile = with_profile.then_some(repository.profile.as_str());
let others = Line::from(spans(repository, profile, 0)).width();
let spans = spans(repository, profile, width.saturating_sub(others));
let line = Line::from(spans);
if focused && position == app.repository {
line.style(selected())
} else {
line
}
})
.collect();
frame.render_widget(
Paragraph::new(lines).block(list_block("Repositories", focused, theme)),
area,
);
}
fn badge(job: &JobSummary, theme: Theme) -> Span<'static> {
let (word, tone) = human::outcome(job);
Span::styled(
format!("{:<9}", word.to_uppercase()),
theme.tone(tone).add_modifier(Modifier::BOLD),
)
}
fn finished_ago(job: &JobSummary, now: u64) -> String {
job.timeline
.finished_at
.map_or_else(String::new, |at| human::ago(at, now))
}
fn latest(frame: &mut Frame<'_>, area: Rect, app: &App, now: u64, theme: Theme) {
let focused = app.pane == Pane::Latest;
let width = usize::from(area.width);
let rows: Vec<_> = app
.latest_rows()
.map(|row| match row {
LatestRow::Queued(job) => (
Span::styled(
format!("{:<9}", "QUEUED"),
theme.tone(Tone::Busy).add_modifier(Modifier::BOLD),
),
format!("{} {}", human::queued_title(job), job.repository),
String::new(),
human::queued_ago(job, now),
),
LatestRow::Finished(job) => (
badge(job, theme),
format!("{} {}", human::title(job), job.repository),
human::ran(job, now).map_or_else(String::new, duration),
finished_ago(job, now),
),
})
.collect();
let ran_width = rows
.iter()
.map(|(_, _, ran, _)| ran.width())
.max()
.unwrap_or(0);
let ago_width = rows
.iter()
.map(|(_, _, _, ago)| ago.width())
.max()
.unwrap_or(0);
let name_width = width
.saturating_sub(10 + ran_width + 1 + ago_width + 1)
.max(8);
let lines: Vec<Line<'_>> = rows
.into_iter()
.enumerate()
.map(|(position, (badge, name, ran, ago))| {
let line = Line::from(vec![
badge,
Span::raw(" "),
Span::raw(fit(&name, name_width)),
Span::raw(format!(" {ran:>ran_width$} ")),
Span::styled(format!("{ago:>ago_width$}"), theme.dim()),
]);
if focused && position == app.latest {
line.style(selected())
} else {
line
}
})
.collect();
frame.render_widget(
Paragraph::new(lines).block(list_block("Latest jobs", focused, theme)),
area,
);
}
fn history(frame: &mut Frame<'_>, area: Rect, app: &App, now: u64, theme: Theme) {
let filters = &app.history;
let [title, table_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
let page = filters.previous.len() + 1;
heading(
frame,
title,
"History",
format!(
"outcome {} ยท repository {} ยท page {page}",
filters.conclusion.as_deref().unwrap_or("any"),
filters.repository.as_deref().unwrap_or("any"),
),
theme,
);
let Some(jobs) = &filters.page else {
frame.render_widget(Line::styled("readingโฆ", theme.dim()), table_area);
return;
};
if jobs.jobs.is_empty() {
frame.render_widget(Line::styled("no jobs", theme.dim()), table_area);
return;
}
let wide = area.width >= 110;
let finished = jobs
.jobs
.iter()
.map(|job| finished_ago(job, now).width())
.chain(["FINISHED".len()])
.max()
.unwrap_or(0);
let mut columns = vec![("OUTCOME", 9), ("REPOSITORY", 0), ("JOB", 0)];
if wide {
columns.push(("BRANCH", 14));
}
columns.extend([("WAITED", 7), ("RAN", 8), ("MEM", 6)]);
if wide {
columns.push(("CPU", 5));
}
columns.push(("FINISHED", finished));
let fixed: usize = columns.iter().map(|(_, width)| width + 1).sum::<usize>() - 1;
let shared = usize::from(table_area.width).saturating_sub(fixed);
let repository_width = shared / 2;
let job_width = shared - repository_width;
let rows = jobs.jobs.iter().map(|job| {
let mut cells = vec![
Cell::from(badge(job, theme)),
Cell::from(fit(&job.repository, repository_width)),
Cell::from(fit(&human::title(job), job_width)),
];
if wide {
cells.push(Cell::from(fit(
job.branch.as_deref().unwrap_or_default(),
14,
)));
}
cells.extend([
Cell::from(human::waited(job, now).map_or_else(String::new, duration)),
Cell::from(human::ran(job, now).map_or_else(String::new, duration)),
Cell::from(job.metrics.memory_peak.map_or_else(String::new, bytes)),
]);
if wide {
cells.push(Cell::from(
job.metrics.cpu_average.map_or_else(String::new, cores),
));
}
cells.push(Cell::from(Span::styled(
finished_ago(job, now),
theme.dim(),
)));
Row::new(cells)
});
let header: Vec<_> = columns.iter().map(|(name, _)| *name).collect();
let widths: Vec<_> = columns
.iter()
.map(|(name, width)| match *name {
"REPOSITORY" => Constraint::Length(u16::try_from(repository_width).unwrap_or(0)),
"JOB" => Constraint::Fill(1),
_ => Constraint::Length(u16::try_from(*width).unwrap_or(u16::MAX)),
})
.collect();
let table = Table::new(rows, widths)
.header(Row::new(header).style(theme.dim()))
.row_highlight_style(selected());
let mut state = TableState::default().with_selected(Some(filters.selected));
frame.render_stateful_widget(table, table_area, &mut state);
}
fn records(frame: &mut Frame<'_>, area: Rect, app: &App, now: u64, theme: Theme) {
let [title, table_area, hint] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(area);
let Some(records) = &app.records else {
heading(frame, title, "Records", String::new(), theme);
frame.render_widget(Line::styled("readingโฆ", theme.dim()), table_area);
return;
};
let limits = &records.limits;
heading(
frame,
title,
"Records",
format!(
"{} of {} ยท {} of {} ยท each for {}",
records.records.len(),
limits.max_count,
bytes(records.bytes()),
bytes(limits.max_total),
duration(limits.max_age)
),
theme,
);
if records.records.is_empty() {
frame.render_widget(
Paragraph::new(Line::styled(
"none kept: a profile with record_failures = true keeps each failed job's runner as a record, \
to open a shell where it failed",
theme.dim(),
))
.wrap(Wrap { trim: true }),
table_area,
);
return;
}
let made: Vec<String> = records
.records
.iter()
.map(|record| {
let mut made = human::ago(record.created_at, now);
if records.shells.contains(&record.job) {
made.push_str(", shell open");
}
made
})
.collect();
let made_width = made.iter().map(|made| made.width()).max().unwrap_or(0);
let fixed = [12, 6, made_width.max("MADE".len())];
let shared = usize::from(table_area.width).saturating_sub(fixed.iter().sum::<usize>() + 4);
let repository_width = shared / 2;
let name_width = shared - repository_width;
let rows = records.records.iter().zip(made).map(|(record, made)| {
Row::new(vec![
Cell::from(record.job.to_string()),
Cell::from(fit(&record.repository, repository_width)),
Cell::from(fit(&human::record_title(record), name_width)),
Cell::from(bytes(record.bytes)),
Cell::from(Span::styled(made, theme.dim())),
])
});
let length = |width: usize| Constraint::Length(u16::try_from(width).unwrap_or(u16::MAX));
let table = Table::new(
rows,
[
length(fixed[0]),
length(repository_width),
Constraint::Fill(1),
length(fixed[1]),
length(fixed[2]),
],
)
.header(Row::new(["JOB", "REPOSITORY", "JOB NAME", "SIZE", "MADE"]).style(theme.dim()))
.row_highlight_style(selected());
let mut state = TableState::default().with_selected(Some(app.record));
frame.render_stateful_widget(table, table_area, &mut state);
if let Some(record) = records.records.get(app.record) {
frame.render_widget(
Line::styled(
format!(
"runnerctl shell {} opens a shell in its workspace",
record.job
),
theme.dim(),
),
hint,
);
}
}
fn sizing(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) {
let Some(sizing) = &app.sizing else {
let [title, rest] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
heading(frame, title, "Sizing", String::new(), theme);
frame.render_widget(Line::styled("readingโฆ", theme.dim()), rest);
return;
};
let wide = area.width >= 100;
let profiles = u16::try_from(sizing.profiles.len()).unwrap_or(u16::MAX);
let [title, profiles_area, advice_area, jobs_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(profiles.saturating_add(1)),
Constraint::Length(3),
Constraint::Min(0),
])
.areas(area);
heading(
frame,
title,
"Sizing",
format!(
"{} of history ยท peaks every {} s +{}% ยท advice after {} jobs",
duration(sizing.until.saturating_sub(sizing.since)),
sizing.sample_interval,
sizing.margin,
sizing.min_jobs
),
theme,
);
sizing_profiles(frame, profiles_area, app, sizing, theme);
let Some(profile) = sizing.profiles.get(app.sized) else {
return;
};
let width = usize::from(advice_area.width);
let mut lines = vec![Line::styled(
format!("{}: {}", profile.name, sizing.advice_line(profile)),
theme.tone(advice_tone(profile.advice)),
)];
if let Some(change) = profile.advice.change() {
lines.push(Line::styled(
fit(
&format!(
"a writes memory = \"{}\", slots = {} into the config; the daemon checks it first",
ByteSize(change.memory),
change.slots
),
width,
),
theme.dim(),
));
}
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), advice_area);
sizing_jobs(frame, jobs_area, profile, wide, theme);
}
fn sizing_profiles(
frame: &mut Frame<'_>,
profiles_area: Rect,
app: &App,
sizing: &Sizing,
theme: Theme,
) {
let wide = profiles_area.width >= 100;
let mut columns = vec![("PROFILE", 0), ("MEMORY", 10), ("JOBS", 5)];
if wide {
columns.push(("P50", 6));
}
columns.push(("P95", 6));
if wide {
columns.push(("P99", 6));
}
columns.push(("MAX", 6));
if wide {
columns.push(("AT CAP", 6));
}
columns.extend([("CPU P99", 7), ("BUSY", 5), ("WAIT P95", 8), ("ADVICE", 11)]);
let rows = sizing.profiles.iter().map(|profile| {
let peaks = &profile.peaks;
let mut cells = vec![
Cell::from(Span::styled(
profile.name.clone(),
theme.profile(app.profile_index(&profile.name)),
)),
Cell::from(format!("{} ร {}", bytes(profile.memory), profile.slots)),
Cell::from(peaks.jobs.to_string()),
];
if wide {
cells.push(Cell::from(bytes(peaks.memory.p50)));
}
cells.push(Cell::from(bytes(peaks.memory.p95)));
if wide {
cells.push(Cell::from(bytes(peaks.memory.p99)));
}
cells.push(Cell::from(bytes(peaks.memory.max)));
if wide {
cells.push(Cell::from(if peaks.at_cap > 0 {
Span::styled(peaks.at_cap.to_string(), theme.tone(Tone::Bad))
} else {
Span::raw("")
}));
}
cells.extend([
Cell::from(cores(peaks.cpu.p99)),
Cell::from(format!("{}%", profile.busy)),
Cell::from(duration(profile.wait.p95)),
Cell::from(Span::styled(
advice_word(profile.advice),
theme.tone(advice_tone(profile.advice)),
)),
]);
Row::new(cells)
});
let table = Table::new(rows, widths(&columns))
.header(Row::new(columns.iter().map(|(name, _)| *name)).style(theme.dim()))
.row_highlight_style(selected());
let mut state = TableState::default().with_selected(Some(app.sized));
frame.render_stateful_widget(table, profiles_area, &mut state);
}
fn sizing_jobs(
frame: &mut Frame<'_>,
jobs_area: Rect,
profile: &ProfileSizing,
wide: bool,
theme: Theme,
) {
let mut columns = vec![("REPOSITORY", 0), ("JOB", 0), ("RUNS", 5)];
columns.push(("P95", 6));
if wide {
columns.push(("P99", 6));
}
columns.extend([("MAX", 6), ("CPU P99", 7), ("WAIT P95", 8), ("NEEDS", 11)]);
let fixed: usize = columns.iter().map(|(_, width)| width + 1).sum::<usize>() - 1;
let shared = usize::from(jobs_area.width).saturating_sub(fixed);
let repository_width = shared / 2;
let rows = profile.jobs.iter().map(|job| {
let needs = match job.advice {
Advice::TooFew { .. } => {
Span::styled(format!("max {}", bytes(job.peaks.memory.max)), theme.dim())
}
Advice::Raise { memory, .. } | Advice::SliceTooSmall { memory } => {
Span::styled(format!("{} > cap", bytes(memory)), theme.tone(Tone::Bad))
}
Advice::Keep { memory } | Advice::Lower { memory, .. } => Span::raw(bytes(memory)),
};
let name = match &job.workflow {
Some(workflow) => format!("{workflow} / {}", job.name),
None => job.name.clone(),
};
let mut cells = vec![
Cell::from(fit(&job.repository, repository_width)),
Cell::from(fit(&name, shared - repository_width)),
Cell::from(job.peaks.jobs.to_string()),
Cell::from(bytes(job.peaks.memory.p95)),
];
if wide {
cells.push(Cell::from(bytes(job.peaks.memory.p99)));
}
cells.extend([
Cell::from(bytes(job.peaks.memory.max)),
Cell::from(cores(job.peaks.cpu.p99)),
Cell::from(duration(job.wait.p95)),
Cell::from(needs),
]);
Row::new(cells)
});
let mut constraints = widths(&columns);
constraints[0] = Constraint::Length(u16::try_from(repository_width).unwrap_or(0));
let table = Table::new(rows, constraints)
.header(Row::new(columns.iter().map(|(name, _)| *name)).style(theme.dim()));
frame.render_widget(table, jobs_area);
}
fn widths(columns: &[(&str, usize)]) -> Vec<Constraint> {
columns
.iter()
.map(|(_, width)| match width {
0 => Constraint::Fill(1),
width => Constraint::Length(u16::try_from(*width).unwrap_or(u16::MAX)),
})
.collect()
}
fn advice_tone(advice: Advice) -> Tone {
match advice {
Advice::Raise { .. } | Advice::SliceTooSmall { .. } => Tone::Bad,
Advice::Lower { .. } => Tone::Busy,
Advice::Keep { .. } => Tone::Good,
Advice::TooFew { .. } => Tone::Plain,
}
}
fn advice_word(advice: Advice) -> String {
match advice {
Advice::TooFew { .. } => "too few".to_owned(),
Advice::Keep { .. } => "keep".to_owned(),
Advice::SliceTooSmall { .. } => "grow slice".to_owned(),
Advice::Lower { memory, slots } | Advice::Raise { memory, slots } => {
format!("{} ร {slots}", bytes(memory))
}
}
}
fn job(frame: &mut Frame<'_>, area: Rect, app: &App, open: &JobView, now: u64, theme: Theme) {
let job = &open.job;
let summary = &job.summary;
let steps_wanted = u16::try_from(job.steps.len() + 1).unwrap_or(u16::MAX);
let fixed = 3 + 2;
let full_charts = area.height >= fixed + steps_wanted + 7 + 6;
let charts_height = if full_charts { 7 } else { 2 };
let steps_height = steps_wanted.min(area.height.saturating_sub(fixed + charts_height + 4));
let [head, timeline_area, steps_area, charts_area, log_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(2),
Constraint::Length(steps_height),
Constraint::Length(charts_height),
Constraint::Min(0),
])
.areas(area);
let profile = summary.runner.as_ref().and_then(|runner| {
app.live
.status
.profiles
.iter()
.find(|p| p.name == runner.profile)
});
let mut details = vec![];
if let Some(branch) = &summary.branch {
details.push((3, format!("branch {branch}")));
}
details.push((5, format!("run {}", summary.run_id)));
if let Some(runner) = &summary.runner {
details.push((1, format!("slot {} ยท {}", runner.profile, runner.slot)));
match (runner.memory_cap, runner.cpu_cap, profile) {
(Some(memory), Some(cpu), _) => {
details.push((
4,
format!("capped at {} and {} CPUs", bytes(memory), cores(cpu)),
));
}
(_, _, Some(profile)) => details.push((
4,
format!(
"profile now {} and {} CPUs",
bytes(profile.memory),
profile.cpus
),
)),
_ => {}
}
}
if let Some(waited) = human::waited(summary, now) {
details.push((2, format!("waited {}", duration(waited))));
}
if let Some(ran) = human::ran(summary, now) {
details.push((0, format!("ran {}", duration(ran))));
}
if job
.record
.as_ref()
.is_some_and(|note| note.bytes.is_some() && note.removed_at.is_none())
{
details.push((1, format!("record kept: runnerctl shell {}", summary.id)));
}
let width = usize::from(head.width);
let details = fit_details(details, width);
let badge = badge(summary, theme);
let title = human::title(summary);
let title_width = title.width().min(width.saturating_sub(badge.width() + 2));
let style = summary.runner.as_ref().map_or_else(
|| theme.dim(),
|runner| theme.profile(app.profile_index(&runner.profile)),
);
frame.render_widget(
Paragraph::new(vec![
Line::from(vec![
Span::styled("โ ", style),
Span::styled(
fit(&summary.repository, width.saturating_sub(2)),
theme.dim(),
),
]),
Line::from(vec![
Span::styled(fit(&title, title_width), bold()),
Span::raw(" "),
badge,
]),
Line::styled(details, theme.dim()),
]),
head,
);
timeline(frame, timeline_area, summary, now, theme);
steps(frame, steps_area, job, now, theme);
if full_charts {
job_charts(frame, charts_area, app, open, theme);
} else {
job_spark(frame, charts_area, app, open, theme);
}
log(frame, log_area, open, theme);
}
fn fit_details(mut details: Vec<(u8, String)>, width: usize) -> String {
loop {
let line = details
.iter()
.map(|(_, detail)| detail.as_str())
.collect::<Vec<_>>()
.join(" ยท ");
if line.width() <= width {
return line;
}
let Some(least) = details
.iter()
.enumerate()
.max_by_key(|(_, (rank, _))| *rank)
.map(|(position, _)| position)
else {
return line;
};
details.remove(least);
}
}
fn timeline(frame: &mut Frame<'_>, area: Rect, job: &JobSummary, now: u64, theme: Theme) {
let timeline = &job.timeline;
let end = timeline.finished_at.unwrap_or(now);
let started = timeline.started_at;
let container = timeline.container_started_at.or(started);
let waiting = container.unwrap_or(end).saturating_sub(timeline.queued_at);
let starting = match (container, started) {
(Some(container), Some(started)) => started.saturating_sub(container),
_ => 0,
};
let running = started.map_or(0, |started| end.saturating_sub(started));
let whole = waiting + starting + running;
let width = area.width;
let starting_cells = scale(starting, whole, width).max(u16::from(starting > 0));
let waiting_cells = scale(waiting, whole, width).max(u16::from(waiting > 0));
let running_cells = width.saturating_sub(waiting_cells + starting_cells);
let still = if timeline.finished_at.is_none() {
"โฆ"
} else {
""
};
let [bar, legend] = Layout::vertical([Constraint::Length(1); 2]).areas(area);
frame.render_widget(
Line::from(vec![
Span::styled("โ".repeat(usize::from(waiting_cells)), theme.wait()),
Span::styled("โ".repeat(usize::from(starting_cells)), theme.cpu()),
Span::styled(
"โ".repeat(usize::from(if started.is_some() {
running_cells
} else {
0
})),
theme.memory(),
),
]),
bar,
);
let mut spans = vec![
Span::styled("โ ", theme.wait()),
Span::raw(format!("waiting for a runner {} ", duration(waiting))),
];
if started.is_some() {
spans.extend([
Span::styled("โ ", theme.cpu()),
Span::raw(format!("runner starting {} ", duration(starting))),
Span::styled("โ ", theme.memory()),
Span::raw(format!("running {}{still}", duration(running))),
]);
}
frame.render_widget(Line::from(spans), legend);
}
fn steps(frame: &mut Frame<'_>, area: Rect, job: &Job, now: u64, theme: Theme) {
if area.height == 0 {
return;
}
let [title, rest] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
let start = job
.summary
.timeline
.started_at
.or_else(|| job.steps.iter().filter_map(|step| step.started_at).min());
let end = job.summary.timeline.finished_at.unwrap_or_else(|| {
if job.summary.state.is_final() {
job.steps
.iter()
.filter_map(|step| step.completed_at)
.max()
.unwrap_or(now)
} else {
now
}
});
heading(
frame,
title,
"Steps",
start.map_or_else(String::new, |start| {
format!(
"{} in {} steps",
duration(end.saturating_sub(start)),
job.steps.len()
)
}),
theme,
);
let Some(start) = start else {
return;
};
let span = end.saturating_sub(start).max(1);
let shown = usize::from(rest.height);
let current = watch::current_step(job).map_or(0, |(_, position)| position);
let first = current
.saturating_sub(shown / 2)
.min(job.steps.len().saturating_sub(shown));
let name_width = job
.steps
.iter()
.map(|step| step.name.width())
.max()
.unwrap_or(0)
.min(usize::from(rest.width) / 3)
.max(8);
let bar_width = rest
.width
.saturating_sub(u16::try_from(name_width).unwrap_or(u16::MAX) + 2 + 10);
let lines: Vec<Line<'_>> = job
.steps
.iter()
.skip(first)
.take(shown)
.map(|step| step_line(step, start, span, now, name_width, bar_width, theme))
.collect();
frame.render_widget(Paragraph::new(lines), rest);
}
fn step_line(
step: &Step,
start: u64,
span: u64,
now: u64,
name_width: usize,
bar_width: u16,
theme: Theme,
) -> Line<'static> {
let running = step.status == "in_progress";
let name_style = match (step.started_at, running) {
(_, true) => bold(),
(None, _) => theme.dim(),
_ => Style::new(),
};
let mut spans = vec![Span::styled(
format!("{} ", fit(&step.name, name_width)),
name_style,
)];
let Some(started) = step.started_at else {
spans.push(Span::raw(" ".repeat(usize::from(bar_width))));
spans.push(Span::styled(format!(" {:>9}", "โ"), theme.dim()));
return Line::from(spans);
};
let finished = step
.completed_at
.unwrap_or(if running { now } else { started });
let offset = scale(started.saturating_sub(start), span, bar_width);
let length = scale(finished.saturating_sub(started), span, bar_width)
.max(1)
.min(bar_width.saturating_sub(offset));
let style = if running {
theme.cpu()
} else if matches!(step.conclusion.as_deref(), Some("failure" | "cancelled")) {
theme.tone(Tone::Bad)
} else if step.conclusion.as_deref() == Some("skipped") {
theme.dim()
} else {
theme.memory()
};
let glyph = if running { "โ" } else { "โ" };
spans.extend([
Span::raw(" ".repeat(usize::from(offset))),
Span::styled(glyph.repeat(usize::from(length)), style),
Span::raw(" ".repeat(usize::from(bar_width - offset - length))),
Span::styled(
format!(
" {:>9}",
format!(
"{}{}",
duration(finished.saturating_sub(started)),
if running { "โฆ" } else { "" }
)
),
bold(),
),
]);
Line::from(spans)
}
fn caps(app: &App, job: &JobSummary) -> (u64, u64) {
let runner = job.runner.as_ref();
let profile = runner.and_then(|runner| {
app.live
.status
.profiles
.iter()
.find(|profile| profile.name == runner.profile)
});
(
runner
.and_then(|runner| runner.memory_cap)
.or(profile.map(|profile| profile.memory))
.unwrap_or(0),
runner
.and_then(|runner| runner.cpu_cap)
.or(profile.map(|profile| u64::from(profile.cpus) * 1000))
.unwrap_or(0),
)
}
fn metrics_line(open: &JobView, width: usize, theme: Theme) -> Line<'static> {
let metrics = &open.job.summary.metrics;
let peak_memory = metrics
.memory_peak
.or_else(|| open.samples.iter().map(|sample| sample.memory).max());
let peak_cpu = metrics
.cpu_peak
.or_else(|| open.samples.iter().map(|sample| sample.cpu).max());
let mut parts = vec![
("mem peak", peak_memory.map(bytes)),
("avg", metrics.memory_average.map(bytes)),
("cpu peak", peak_cpu.map(cores)),
("avg", metrics.cpu_average.map(cores)),
("cpu time", metrics.cpu_time.map(human::cpu_time)),
("in", metrics.network_received.map(bytes)),
("out", metrics.network_sent.map(bytes)),
];
if metrics.mirror_bytes > 0 {
parts.push(("mirror", Some(bytes(metrics.mirror_bytes))));
}
let mut line = Line::default();
for (label, value) in parts {
let Some(value) = value else { continue };
let gap = if line.spans.is_empty() { "" } else { " " };
if line.width() + gap.width() + label.width() + 1 + value.width() > width {
break;
}
line.push_span(Span::styled(format!("{gap}{label} "), theme.dim()));
line.push_span(Span::styled(value, bold()));
}
line
}
fn job_charts(frame: &mut Frame<'_>, area: Rect, app: &App, open: &JobView, theme: Theme) {
let [charts, metrics] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area);
let [memory_area, cpu_area] =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(charts);
let (memory_cap, cpu_cap) = caps(app, &open.job.summary);
Chart {
title: "Memory",
limit_label: format!("cap {}", bytes(memory_cap)),
values: open.samples.iter().map(|sample| sample.memory).collect(),
limit: memory_cap,
unit: bytes,
style: theme.memory(),
}
.render(frame, memory_area, theme);
Chart {
title: "CPU",
limit_label: format!("cap {} CPUs", cores(cpu_cap)),
values: open.samples.iter().map(|sample| sample.cpu).collect(),
limit: cpu_cap,
unit: in_cores,
style: theme.cpu(),
}
.render(frame, cpu_area, theme);
frame.render_widget(
metrics_line(open, usize::from(metrics.width), theme),
metrics,
);
}
fn job_spark(frame: &mut Frame<'_>, area: Rect, app: &App, open: &JobView, theme: Theme) {
let [sparks, metrics] = Layout::vertical([Constraint::Length(1); 2]).areas(area);
let (memory_cap, cpu_cap) = caps(app, &open.job.summary);
let width = usize::from(area.width / 2).saturating_sub(16);
let last = open.samples.back();
frame.render_widget(
Line::from(vec![
Span::styled("mem ", theme.dim()),
Span::raw(format!(
"{:>6} ",
last.map_or_else(String::new, |s| bytes(s.memory))
)),
Span::styled(
format!(
"{:<width$} ",
spark(open.samples.iter().map(|s| s.memory), memory_cap, width)
),
theme.memory(),
),
Span::styled("cpu ", theme.dim()),
Span::raw(format!(
"{:>5} ",
last.map_or_else(String::new, |s| cores(s.cpu))
)),
Span::styled(
spark(open.samples.iter().map(|s| s.cpu), cpu_cap, width),
theme.cpu(),
),
]),
sparks,
);
frame.render_widget(
metrics_line(open, usize::from(metrics.width), theme),
metrics,
);
}
fn log(frame: &mut Frame<'_>, area: Rect, open: &JobView, theme: Theme) {
let summary = &open.job.summary.log;
let following = if open.scroll == 0 {
"following".to_owned()
} else {
format!("{} lines up, G follows", open.scroll)
};
let block = Block::new()
.borders(Borders::TOP)
.border_style(theme.dim())
.title(Span::styled(" Log ", bold()))
.title_top(
Line::styled(
format!(
" {} lines, {}{} ยท {following} ",
summary.lines.max(open.log.len() as u64),
bytes(summary.bytes),
if summary.truncated { ", cut" } else { "" }
),
theme.dim(),
)
.right_aligned(),
);
let inner = block.inner(area);
frame.render_widget(block, area);
let height = usize::from(inner.height);
let end = open.log.len().saturating_sub(open.scroll);
let start = end.saturating_sub(height);
let lines: Vec<Line<'_>> = open
.log
.range(start..end)
.map(|line| {
let line = without_timestamp(line);
let style = if line.starts_with("##[group]") {
bold()
} else if line.starts_with("##[error]") {
theme.tone(Tone::Bad)
} else if line.starts_with("##[") {
theme.dim()
} else {
Style::new()
};
Line::styled(line.to_owned(), style)
})
.collect();
frame.render_widget(Paragraph::new(lines), inner);
}
fn without_timestamp(line: &str) -> &str {
match line.split_once(' ') {
Some((stamp, rest))
if stamp.len() >= 20
&& stamp.ends_with('Z')
&& stamp.as_bytes().get(10) == Some(&b'T')
&& stamp.starts_with(|c: char| c.is_ascii_digit()) =>
{
rest
}
_ => line,
}
}