use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEventKind};
use ratatui::{
layout::Flex,
prelude::*,
style::{Color, Style},
widgets::*,
};
use ratatui_textarea::{CursorMove, TextArea};
use crate::app::Action;
use crate::columns::JobColumn;
use crate::job::{explain_reason, reason_code, Job, JobStatus};
use crate::job_rows::JobRow;
use crate::joblist::{JobList, JobListAction};
use crate::menus::help::HelpContext;
use crate::menus::OpenMenu;
use crate::mouse_input::MouseInput;
#[derive(Debug, Clone, PartialEq)]
pub enum WindowFocus {
JobDetails,
Log,
}
#[derive(Default)]
pub struct MouseAreas {
pub joblist_title: Rect,
pub squeue_command: Rect,
pub details_title: Rect,
pub bottom_symbol: Rect,
pub log_title: Rect,
pub joblist: Rect,
pub categories: Vec<Rect>,
}
pub struct JobOverview {
pub collapsed_top: bool, pub collapsed_bot: bool, pub focus: WindowFocus, pub state: TableState, pub mouse_areas: MouseAreas, pub squeue_command: TextArea<'static>, pub edit_squeue: bool, pub refresh_rate: usize, pub log_height: u16, pub columns: Vec<JobColumn>, pub filter_prompt: bool, pub filter_input: String,
}
impl JobOverview {
pub fn new(refresh_rate: usize, squeue_command: &str, columns: Vec<JobColumn>) -> Self {
let mut state = TableState::default();
state.select(Some(0));
let mut mouse_areas = MouseAreas::default();
for _ in 0..columns.len() {
mouse_areas.categories.push(Rect::default());
}
let command = squeue_command.to_string();
let mut textarea = TextArea::from([command]);
textarea.move_cursor(CursorMove::End);
Self {
collapsed_top: false,
collapsed_bot: true,
focus: WindowFocus::JobDetails,
state,
mouse_areas,
squeue_command: textarea,
edit_squeue: false,
refresh_rate,
log_height: 0,
columns,
filter_prompt: false,
filter_input: String::new(),
}
}
}
impl JobOverview {
fn get_squeue_command(&self) -> String {
self.squeue_command.lines().join("\n")
}
fn exit_squeue_edit(&mut self, action: &mut Action) {
let new_command = self.get_squeue_command();
*action = Action::UpdateJobList(JobListAction::UpdateSqueueCommand(new_command));
self.edit_squeue = false;
}
fn apply_filter(&self, action: &mut Action) {
*action = Action::UpdateJobList(JobListAction::SetFilter(self.filter_input.clone()));
}
fn clear_filter(&mut self, action: &mut Action) {
self.filter_input.clear();
self.apply_filter(action);
}
}
impl JobOverview {
pub fn render(&mut self, f: &mut Frame, area: &Rect, jobs: &JobList) {
let mut constraints = vec![Constraint::Length(1)];
if self.collapsed_top && self.collapsed_bot {
constraints.push(Constraint::Length(1));
constraints.push(Constraint::Length(1));
} else if self.collapsed_top && !self.collapsed_bot {
constraints.push(Constraint::Length(1));
constraints.push(Constraint::Min(1));
} else if !self.collapsed_top && self.collapsed_bot {
constraints.push(Constraint::Min(1));
constraints.push(Constraint::Length(1));
} else {
constraints.push(Constraint::Percentage(30));
constraints.push(Constraint::Percentage(70));
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints.as_slice())
.split(*area);
self.render_title(f, &layout[0]);
self.render_joblist(f, &layout[1], jobs);
self.render_bottom_section(f, &layout[2], jobs);
}
fn render_title(&self, f: &mut Frame, area: &Rect) {
f.render_widget(
Paragraph::new("SLURM TASK MANAGER")
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center),
*area,
);
}
fn render_joblist(&mut self, f: &mut Frame, area: &Rect, jobs: &JobList) {
self.state.select(Some(jobs.get_index()));
match self.collapsed_top {
true => self.render_joblist_collapsed(f, area, jobs),
false => self.render_joblist_extended(f, area, jobs),
}
}
fn render_joblist_collapsed(&mut self, f: &mut Frame, area: &Rect, jobs: &JobList) {
self.mouse_areas.joblist_title = *area;
self.mouse_areas.joblist = Rect::default();
let job = match jobs.get_job() {
Some(job) => job,
None => {
let title = "▶ Job list (collapsed)".to_string();
f.render_widget(Line::from(title), *area);
return;
}
};
let col = get_job_color(job);
let mut content_strings = vec!["▶ Job: ".to_string()];
content_strings.extend(self.columns.iter().map(|column| column.cell(job)));
let constraints = content_strings
.iter()
.map(|s| Constraint::Min(s.len() as u16 + 2))
.collect::<Vec<Constraint>>();
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints::<Vec<Constraint>>(constraints)
.split(*area);
for category in self.mouse_areas.categories.iter_mut() {
*category = Rect::default();
}
content_strings.iter().enumerate().for_each(|(i, s)| {
let line = Line::from(s.clone()).style(Style::default().fg(col));
f.render_widget(line, layout[i]);
});
}
fn render_joblist_extended(&mut self, f: &mut Frame, area: &Rect, jobs: &JobList) {
let title = "▼ Job list: ";
let title_len = title.len() as u16;
let refresh_rate = format!("{} ms", self.refresh_rate);
let mut block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title_top(Line::from(refresh_rate).alignment(Alignment::Right));
if self.filter_prompt {
block = block.title_bottom(
Line::from(format!(" filter: {}▏", self.filter_input))
.alignment(Alignment::Left)
.style(Style::default().fg(Color::Yellow)),
);
} else if let Some(text) = jobs.filter_text() {
let indicator = format!(
" filter: {} ({}/{}) ",
text,
jobs.filter_match_count(),
jobs.jobs.len()
);
block = block.title_bottom(Line::from(indicator).alignment(Alignment::Left));
}
let mut top_row = *area;
top_row.height = 1;
top_row.width = title_len - 2;
let top_row = top_row.intersection(f.area());
self.mouse_areas.joblist_title = top_row;
let mut joblist_area = block.inner(*area);
f.render_widget(block.clone(), *area);
let buffer = self.get_squeue_command();
let mut squeue_rect = *area;
squeue_rect.height = 1;
squeue_rect.width = buffer.len() as u16 + 1;
squeue_rect.x = title_len - 1;
let squeue_rect = squeue_rect.intersection(f.area());
self.mouse_areas.squeue_command = squeue_rect;
if !squeue_rect.is_empty() {
self.render_squeue_command(f, &squeue_rect);
}
if jobs.is_empty() {
self.render_empty_joblist(f, &joblist_area);
return;
}
let visible_rows = jobs.len();
if visible_rows == 0 {
let text = Span::styled("No jobs match the filter", Style::default().fg(Color::Gray));
let paragraph = Paragraph::new(text).alignment(Alignment::Center);
f.render_widget(paragraph, joblist_area);
return;
}
let mut title_names = self
.columns
.iter()
.map(|column| Span::raw(column.header()))
.collect::<Vec<Span>>();
let cat_ind = self
.columns
.iter()
.position(|column| column == jobs.get_sort_category());
if let Some(cat_ind) = cat_ind {
let new_title = format!(
"{} {}",
self.columns[cat_ind].header(),
if jobs.is_reverse() { "▲" } else { "▼" }
);
title_names[cat_ind] = Span::styled(new_title, Style::default().fg(Color::Blue));
}
let rows = jobs
.rows()
.iter()
.map(|row| self.render_row(row, jobs))
.collect::<Vec<Row>>();
let widths = self
.columns
.iter()
.map(|column| Constraint::Min(column.min_width()))
.collect::<Vec<Constraint>>();
let flex = Flex::SpaceBetween;
let column_spacing = 1;
let mut rects = Layout::horizontal(widths.clone())
.flex(flex)
.spacing(column_spacing)
.split(joblist_area);
rects = rects
.iter()
.map(|rect| {
let mut r = *rect;
r.height = 1;
r
})
.collect();
self.mouse_areas.categories = rects.to_vec();
let table = Table::new(rows, widths)
.column_spacing(column_spacing)
.header(Row::new(title_names).style(Style::new().bold()))
.flex(flex)
.row_highlight_style(Style::new().reversed());
f.render_stateful_widget(table, joblist_area, &mut self.state);
joblist_area.y += 1; joblist_area.height = joblist_area.height.saturating_sub(1);
self.mouse_areas.joblist = joblist_area;
}
fn render_row<'a>(&self, row: &JobRow, jobs: &'a JobList) -> Row<'a> {
match row {
JobRow::Single { job_index } => {
let job = &jobs.jobs[*job_index];
Row::new(
self.columns
.iter()
.map(|column| column.cell(job))
.collect::<Vec<String>>(),
)
.style(Style::default().fg(get_job_color(job)))
}
JobRow::Group {
base_id,
task_indices,
expanded,
} => {
let tasks: Vec<&Job> = task_indices.iter().map(|&i| &jobs.jobs[i]).collect();
Row::new(
self.columns
.iter()
.map(|column| column.group_cell(base_id, &tasks, *expanded))
.collect::<Vec<String>>(),
)
.style(Style::default().fg(get_group_color(&tasks)))
}
JobRow::Task { job_index, last } => {
let job = &jobs.jobs[*job_index];
Row::new(
self.columns
.iter()
.map(|column| {
if *column == JobColumn::Id {
let glyph = if *last { "└" } else { "├" };
format!("{} {}", glyph, job.id)
} else {
column.cell(job)
}
})
.collect::<Vec<String>>(),
)
.style(Style::default().fg(get_job_color(job)))
}
}
}
fn render_squeue_command(&mut self, f: &mut Frame, area: &Rect) {
let textarea = &mut self.squeue_command;
if self.edit_squeue {
textarea.set_cursor_style(Style::default().bg(Color::Red));
textarea.set_cursor_line_style(
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
);
} else {
textarea.set_cursor_line_style(Style::default());
textarea.set_cursor_style(Style::default());
}
f.render_widget(&*textarea, *area);
}
fn render_empty_joblist(&self, f: &mut Frame, area: &Rect) {
let text = "No jobs found";
let text = Span::styled(text, Style::default().fg(Color::Gray));
let paragraph = Paragraph::new(text).alignment(Alignment::Center);
f.render_widget(paragraph, *area);
}
fn render_bottom_section(&mut self, f: &mut Frame, area: &Rect, jobs: &JobList) {
self.log_height = area.height.saturating_sub(2);
match self.collapsed_bot {
true => self.render_bottom_collapsed(f, area),
false => self.render_bottom_extended(f, area, jobs),
}
}
fn render_bottom_collapsed(&mut self, f: &mut Frame, area: &Rect) {
let title = vec![
Span::raw("▶ "),
Span::raw("1. Job details"),
Span::raw(" "),
Span::raw("2. Log"),
];
self.update_bottom_mouse_positions(area, title.clone(), 0);
let line = Line::from(title).style(Style::default().fg(Color::Gray));
f.render_widget(line, *area);
}
fn render_bottom_extended(&mut self, f: &mut Frame, area: &Rect, jobs: &JobList) {
let mut title = vec![
Span::raw("▼ "),
Span::raw("1. Job details"),
Span::raw(" "),
Span::raw("2. Log"),
];
self.update_bottom_mouse_positions(area, title.clone(), 1);
match self.focus {
WindowFocus::JobDetails => {
title[1] = Span::styled("1. Job details", Style::default().fg(Color::Blue));
}
WindowFocus::Log => {
title[3] = Span::styled("2. Log", Style::default().fg(Color::Blue));
}
}
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded);
f.render_widget(block.clone(), *area);
let rect = block.inner(*area);
match self.focus {
WindowFocus::JobDetails => {
self.render_job_details(f, &rect, jobs);
}
WindowFocus::Log => {
self.render_log(f, &rect, jobs);
}
}
}
fn render_job_details(&self, f: &mut Frame, area: &Rect, jobs: &JobList) {
let mut lines: Vec<Line> = Vec::new();
if let Some(job) = jobs.get_job() {
append_pending_reason_lines(&mut lines, job);
append_efficiency_lines(&mut lines, job);
}
for line in jobs.get_job_details().lines() {
lines.push(Line::from(line.to_string()));
}
let paragraph = Paragraph::new(Text::from(lines))
.alignment(Alignment::Left)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, *area);
}
fn render_log(&self, f: &mut Frame, area: &Rect, jobs: &JobList) {
let mut paragraph = Paragraph::new(jobs.get_log_tail())
.alignment(Alignment::Left)
.wrap(Wrap { trim: true });
let lines = paragraph.line_count(area.width);
let offset = (lines as u16).saturating_sub(self.log_height);
paragraph = paragraph.scroll((offset, 0));
f.render_widget(paragraph, *area);
}
fn update_bottom_mouse_positions(&mut self, area: &Rect, title: Vec<Span>, offset: u16) {
if title.len() != 4 {
return;
}
let mut top_row = *area;
top_row.height = 1;
let mut symbol = top_row;
symbol.width = title[0].width() as u16;
symbol.x += offset;
let mut details_title = top_row;
details_title.width = title[1].width() as u16;
details_title.x += symbol.width + symbol.x;
let mut log_title = top_row;
log_title.width = title[3].width() as u16;
log_title.x += details_title.width + details_title.x + 2;
self.mouse_areas.bottom_symbol = symbol.intersection(*area);
self.mouse_areas.details_title = details_title.intersection(*area);
self.mouse_areas.log_title = log_title.intersection(*area);
}
}
fn get_job_color(job: &Job) -> Color {
match job.status {
JobStatus::Running => Color::Green,
JobStatus::Pending => Color::Yellow,
JobStatus::Completing => Color::Yellow,
JobStatus::Completed => Color::Gray,
JobStatus::Failed => Color::Red,
JobStatus::Timeout => Color::Red,
JobStatus::Cancelled => Color::Red,
JobStatus::Unknown => Color::Red,
}
}
fn get_group_color(tasks: &[&Job]) -> Color {
if tasks.iter().any(|job| job.status == JobStatus::Running) {
Color::Green
} else if tasks
.iter()
.any(|job| matches!(job.status, JobStatus::Pending | JobStatus::Completing))
{
Color::Yellow
} else if tasks.iter().any(|job| {
matches!(
job.status,
JobStatus::Failed | JobStatus::Timeout | JobStatus::Cancelled | JobStatus::Unknown
)
}) {
Color::Red
} else {
Color::Gray
}
}
fn append_pending_reason_lines(lines: &mut Vec<Line>, job: &Job) {
if job.status != JobStatus::Pending {
return;
}
let raw = job.reason.as_deref().unwrap_or("None");
let code = reason_code(raw);
let text = match explain_reason(code) {
Some(explanation) if code.is_empty() || code == "None" => {
format!("Pending — {}", explanation)
}
Some(explanation) => format!("Pending — {}: {}", code, explanation),
None => format!("Pending — {}", raw),
};
lines.push(Line::from(Span::styled(
text,
Style::default().fg(Color::Yellow).bold(),
)));
lines.push(Line::default());
}
const EFFICIENCY_BAR_WIDTH: usize = 20;
fn append_efficiency_lines(lines: &mut Vec<Line>, job: &Job) {
let stats = match &job.stats {
Some(stats) if stats.has_any() => stats,
_ => return,
};
lines.push(Line::from(Span::styled(
"Efficiency (Mem: red = underused · Time: red = near limit)",
Style::default().fg(Color::Gray),
)));
if let Some(mem) = stats.mem_efficiency {
lines.push(efficiency_line("Mem ", mem, utilization_color(mem)));
}
if let Some(time) = stats.elapsed_frac_of_limit {
lines.push(efficiency_line("Time", time, time_limit_color(time)));
}
lines.push(Line::default());
}
fn efficiency_line(label: &str, fraction: f64, color: Color) -> Line<'static> {
Line::from(vec![
Span::raw(format!("{} ", label)),
Span::styled(
format!(
"{:>4.0}% {}",
fraction * 100.0,
bar_string(fraction, EFFICIENCY_BAR_WIDTH)
),
Style::default().fg(color),
),
])
}
fn bar_string(fraction: f64, width: usize) -> String {
const PARTIAL_BLOCKS: [char; 8] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
let clamped = fraction.clamp(0.0, 1.0);
let eighths = (clamped * (width * 8) as f64).round() as usize;
let mut bar = "█".repeat(eighths / 8);
if !eighths.is_multiple_of(8) {
bar.push(PARTIAL_BLOCKS[eighths % 8]);
}
let filled = bar.chars().count();
bar.push_str(&"░".repeat(width.saturating_sub(filled)));
bar
}
fn utilization_color(fraction: f64) -> Color {
if fraction < 0.3 {
Color::Red
} else if fraction < 0.6 {
Color::Yellow
} else {
Color::Green
}
}
fn time_limit_color(fraction: f64) -> Color {
if fraction < 0.7 {
Color::Green
} else if fraction <= 0.9 {
Color::Yellow
} else {
Color::Red
}
}
impl JobOverview {
pub fn input(&mut self, action: &mut Action, key_event: KeyEvent) -> bool {
if self.edit_squeue {
match key_event.code {
KeyCode::Esc | KeyCode::Enter => {
self.exit_squeue_edit(action);
return true;
}
_ => {
self.squeue_command.input(key_event);
return true;
}
}
}
if self.filter_prompt {
match key_event.code {
KeyCode::Esc => {
self.filter_prompt = false;
self.clear_filter(action);
}
KeyCode::Enter => {
self.filter_prompt = false;
if self.filter_input.is_empty() {
self.apply_filter(action);
}
}
KeyCode::Backspace => {
self.filter_input.pop();
self.apply_filter(action);
}
KeyCode::Char(c) => {
self.filter_input.push(c);
self.apply_filter(action);
}
_ => {}
}
return true;
}
match key_event.code {
KeyCode::Char('q') => {
*action = Action::Quit;
}
KeyCode::Down | KeyCode::Char('j') => {
self.next_job(action);
}
KeyCode::Up | KeyCode::Char('k') => {
self.prev_job(action);
}
KeyCode::Enter | KeyCode::Char('l') => {
*action = Action::OpenMenu(OpenMenu::JobActions);
}
KeyCode::Char('L') => {
*action = Action::OpenMenu(OpenMenu::LogView);
}
KeyCode::Char(' ') => {
*action = Action::UpdateJobList(JobListAction::ToggleGroup);
}
KeyCode::Tab => {
*action = Action::UpdateJobList(JobListAction::NextSortCategory);
}
KeyCode::Char('r') => {
*action = Action::UpdateJobList(JobListAction::ReverseSortDirection);
}
KeyCode::Char('1') => {
self.select_details();
}
KeyCode::Char('2') => {
self.select_log();
}
KeyCode::Right | KeyCode::Left => {
self.toggle_focus();
}
KeyCode::Char('a') => {
*action = Action::OpenMenu(OpenMenu::Salloc);
}
KeyCode::Char('o') => {
*action = Action::OpenMenu(OpenMenu::UserOptions);
}
KeyCode::Char('?') => {
*action = Action::OpenMenu(OpenMenu::Help(HelpContext::JobOverview));
}
KeyCode::Char('m') => {
self.collapsed_top = !self.collapsed_top;
}
KeyCode::Char('n') => {
self.collapsed_bot = !self.collapsed_bot;
}
KeyCode::Char('/') => {
self.collapsed_top = false;
self.edit_squeue = true;
}
KeyCode::Char('f') => {
self.collapsed_top = false;
self.filter_prompt = true;
}
KeyCode::Esc => {
if self.filter_input.is_empty() {
return false;
}
self.clear_filter(action);
}
_ => {
return false;
}
};
true
}
fn select_details(&mut self) {
if self.focus == WindowFocus::JobDetails {
self.collapsed_bot = !self.collapsed_bot;
} else {
self.focus = WindowFocus::JobDetails;
self.collapsed_bot = false;
}
}
fn select_log(&mut self) {
if self.focus == WindowFocus::Log {
self.collapsed_bot = !self.collapsed_bot;
} else {
self.focus = WindowFocus::Log;
self.collapsed_bot = false;
}
}
fn toggle_focus(&mut self) {
self.focus = match self.focus {
WindowFocus::JobDetails => WindowFocus::Log,
WindowFocus::Log => WindowFocus::JobDetails,
};
}
fn next_job(&mut self, action: &mut Action) {
*action = Action::UpdateJobList(JobListAction::Next);
}
fn prev_job(&mut self, action: &mut Action) {
*action = Action::UpdateJobList(JobListAction::Previous);
}
pub fn set_index(&mut self, index: i32) {
self.state.select(Some(index as usize));
}
}
impl JobOverview {
pub fn mouse_input(&mut self, action: &mut Action, mouse_input: &mut MouseInput) {
let mouse_pos = mouse_input.get_position();
if let Some(event_kind) = mouse_input.kind() {
match event_kind {
MouseEventKind::Down(MouseButton::Left) => {
if self.edit_squeue {
self.exit_squeue_edit(action);
return;
}
if self.filter_prompt {
self.filter_prompt = false;
return;
}
if self.mouse_areas.joblist_title.contains(mouse_pos) {
self.collapsed_top = !self.collapsed_top;
mouse_input.click();
}
if self.mouse_areas.squeue_command.contains(mouse_pos) {
self.edit_squeue = true;
mouse_input.click();
}
for (i, category) in self.mouse_areas.categories.iter().enumerate() {
if category.contains(mouse_pos) {
if let Some(new_cat) = self.columns.get(i).copied() {
*action = Action::UpdateJobList(JobListAction::SelectSortCategory(
new_cat,
));
mouse_input.click();
}
}
}
if self.mouse_areas.joblist.contains(mouse_pos) {
let rel_y = mouse_pos.y - self.mouse_areas.joblist.y;
let new_index = rel_y as usize + self.state.offset();
*action = Action::UpdateJobList(JobListAction::Select(new_index));
if mouse_input.is_double_click() {
*action = Action::OpenMenu(OpenMenu::JobActions);
}
mouse_input.click();
}
if self.mouse_areas.bottom_symbol.contains(mouse_pos) {
self.collapsed_bot = !self.collapsed_bot;
mouse_input.click();
}
if self.mouse_areas.details_title.contains(mouse_pos) {
self.select_details();
mouse_input.click();
}
if self.mouse_areas.log_title.contains(mouse_pos) {
self.select_log();
mouse_input.click();
}
}
MouseEventKind::ScrollDown => {
self.next_job(action);
}
MouseEventKind::ScrollUp => {
self.prev_job(action);
}
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn make_running_job() -> Job {
Job::new(
"424242",
"train_model",
JobStatus::Running,
"12:34:56",
"gpu",
2,
"/home/user/project",
"/home/user/project/run.sh",
None,
)
}
fn render_to_string(width: u16, height: u16, jobs: &JobList) -> String {
render_overview(width, height, jobs, false)
}
fn render_with_details(width: u16, height: u16, jobs: &JobList) -> String {
render_overview(width, height, jobs, true)
}
fn render_overview(width: u16, height: u16, jobs: &JobList, expand_details: bool) -> String {
render_overview_with_columns(width, height, jobs, expand_details, JobColumn::defaults())
}
fn render_overview_with_columns(
width: u16,
height: u16,
jobs: &JobList,
expand_details: bool,
columns: Vec<JobColumn>,
) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
let mut overview = JobOverview::new(250, "squeue -u user", columns);
overview.collapsed_bot = !expand_details;
terminal
.draw(|f| {
let area = f.area();
overview.render(f, &area, jobs);
})
.unwrap();
terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect()
}
#[test]
fn test_render_job_overview_with_running_job() {
let mut jobs = JobList::new();
jobs.jobs.push(make_running_job());
jobs.set_index(0).unwrap();
let content = render_to_string(80, 20, &jobs);
assert!(content.contains("SLURM TASK MANAGER"));
assert!(content.contains("424242"));
assert!(content.contains("train_model"));
assert!(content.contains("Running"));
}
#[test]
fn test_render_default_columns_shows_historical_headers() {
let mut jobs = JobList::new();
jobs.jobs.push(make_running_job());
jobs.set_index(0).unwrap();
let content = render_to_string(100, 20, &jobs);
for header in ["ID", "Name", "Status", "Time", "Partition", "Nodes"] {
assert!(content.contains(header), "missing header {:?}", header);
}
assert!(!content.contains("Priority"));
assert!(!content.contains("Account"));
}
#[test]
fn test_render_custom_columns_with_priority() {
let mut job = make_running_job();
job.priority = 4294901760;
let mut jobs = JobList::new();
jobs.jobs.push(job);
jobs.set_index(0).unwrap();
let columns = vec![
JobColumn::Id,
JobColumn::Name,
JobColumn::Status,
JobColumn::Time,
JobColumn::Partition,
JobColumn::Priority,
];
let content = render_overview_with_columns(100, 20, &jobs, false, columns);
assert!(content.contains("Priority"));
assert!(content.contains("4294901760"));
assert!(!content.contains("Nodes"));
}
fn make_array_joblist() -> JobList {
let mut jobs = JobList::new();
let mut task1 = make_running_job();
task1.id = "12345_1".to_string();
let mut task2 = make_running_job();
task2.id = "12345_2".to_string();
task2.status = JobStatus::Pending;
jobs.jobs.push(task1);
jobs.jobs.push(task2);
jobs.jobs.push(make_running_job());
jobs.set_index(0).unwrap();
jobs
}
#[test]
fn test_render_collapsed_group_row_shows_base_id_and_counts() {
let jobs = make_array_joblist();
let content = render_to_string(100, 20, &jobs);
assert!(content.contains("▶ 12345[]"));
assert!(content.contains("1R 1PD"));
assert!(!content.contains("12345_1"));
assert!(!content.contains("12345_2"));
assert!(content.contains("424242"));
}
#[test]
fn test_render_expanded_group_shows_indented_tasks() {
let mut jobs = make_array_joblist();
jobs.handle_joblist_action(JobListAction::ToggleGroup, &JobColumn::defaults());
let content = render_to_string(100, 20, &jobs);
assert!(content.contains("▼ 12345[]"));
assert!(content.contains("├ 12345_1"));
assert!(content.contains("└ 12345_2"));
}
use crossterm::event::KeyModifiers;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn make_filter_joblist() -> JobList {
let mut jobs = JobList::new();
jobs.jobs.push(make_running_job());
let mut preprocess = make_running_job();
preprocess.id = "424243".to_string();
preprocess.name = "preprocess".to_string();
preprocess.status = JobStatus::Pending;
jobs.jobs.push(preprocess);
let mut download = make_running_job();
download.id = "424241".to_string();
download.name = "download_data".to_string();
jobs.jobs.push(download);
jobs.set_index(0).unwrap();
jobs
}
#[test]
fn test_render_active_filter_shows_indicator_and_reduced_rows() {
let mut jobs = make_filter_joblist();
jobs.set_filter("train", &JobColumn::defaults());
let content = render_to_string(100, 20, &jobs);
assert!(content.contains("filter: train (1/3)"));
assert!(content.contains("train_model"));
assert!(!content.contains("preprocess"));
assert!(!content.contains("download_data"));
}
#[test]
fn test_render_without_filter_shows_no_indicator() {
let jobs = make_filter_joblist();
let content = render_to_string(100, 20, &jobs);
assert!(!content.contains("filter:"));
}
#[test]
fn test_render_filter_without_matches_shows_empty_hint() {
let mut jobs = make_filter_joblist();
jobs.set_filter("no such job", &JobColumn::defaults());
let content = render_to_string(100, 20, &jobs);
assert!(content.contains("No jobs match the filter"));
assert!(content.contains("filter: no such job (0/3)"));
assert!(!content.contains("No jobs found"));
}
#[test]
fn test_render_open_filter_prompt_shows_input_and_cursor() {
let jobs = make_filter_joblist();
let backend = TestBackend::new(100, 20);
let mut terminal = Terminal::new(backend).unwrap();
let mut overview = JobOverview::new(250, "squeue -u user", JobColumn::defaults());
let mut action = Action::None;
overview.input(&mut action, key(KeyCode::Char('f')));
for c in "gpu".chars() {
overview.input(&mut action, key(KeyCode::Char(c)));
}
terminal
.draw(|f| {
let area = f.area();
overview.render(f, &area, &jobs);
})
.unwrap();
let content: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(content.contains("filter: gpu▏"));
}
#[test]
fn test_filter_key_opens_prompt_and_typing_emits_filter_actions() {
let mut overview = JobOverview::new(250, "squeue -u user", JobColumn::defaults());
let mut action = Action::None;
overview.collapsed_top = true;
assert!(overview.input(&mut action, key(KeyCode::Char('f'))));
assert!(overview.filter_prompt);
assert!(!overview.collapsed_top);
overview.input(&mut action, key(KeyCode::Char('g')));
match &action {
Action::UpdateJobList(JobListAction::SetFilter(text)) => assert_eq!(text, "g"),
other => panic!("expected SetFilter, got {:?}", other),
}
overview.input(&mut action, key(KeyCode::Char('p')));
overview.input(&mut action, key(KeyCode::Char('u')));
match &action {
Action::UpdateJobList(JobListAction::SetFilter(text)) => assert_eq!(text, "gpu"),
other => panic!("expected SetFilter, got {:?}", other),
}
overview.input(&mut action, key(KeyCode::Backspace));
match &action {
Action::UpdateJobList(JobListAction::SetFilter(text)) => assert_eq!(text, "gp"),
other => panic!("expected SetFilter, got {:?}", other),
}
action = Action::None;
overview.input(&mut action, key(KeyCode::Enter));
assert!(!overview.filter_prompt);
assert_eq!(overview.filter_input, "gp");
assert!(matches!(action, Action::None));
overview.input(&mut action, key(KeyCode::Char('f')));
assert!(overview.filter_prompt);
assert_eq!(overview.filter_input, "gp");
}
#[test]
fn test_escape_in_prompt_cancels_and_clears_the_filter() {
let mut overview = JobOverview::new(250, "squeue -u user", JobColumn::defaults());
let mut action = Action::None;
overview.input(&mut action, key(KeyCode::Char('f')));
overview.input(&mut action, key(KeyCode::Char('x')));
overview.input(&mut action, key(KeyCode::Esc));
assert!(!overview.filter_prompt);
assert_eq!(overview.filter_input, "");
match &action {
Action::UpdateJobList(JobListAction::SetFilter(text)) => assert!(text.is_empty()),
other => panic!("expected a clearing SetFilter, got {:?}", other),
}
}
#[test]
fn test_escape_outside_prompt_clears_an_active_filter() {
let mut overview = JobOverview::new(250, "squeue -u user", JobColumn::defaults());
let mut action = Action::None;
assert!(!overview.input(&mut action, key(KeyCode::Esc)));
overview.input(&mut action, key(KeyCode::Char('f')));
overview.input(&mut action, key(KeyCode::Char('a')));
overview.input(&mut action, key(KeyCode::Enter));
assert_eq!(overview.filter_input, "a");
action = Action::None;
assert!(overview.input(&mut action, key(KeyCode::Esc)));
assert_eq!(overview.filter_input, "");
match &action {
Action::UpdateJobList(JobListAction::SetFilter(text)) => assert!(text.is_empty()),
other => panic!("expected a clearing SetFilter, got {:?}", other),
}
}
#[test]
fn test_render_narrow_terminal_does_not_panic() {
let mut jobs = JobList::new();
jobs.jobs.push(make_running_job());
jobs.set_index(0).unwrap();
render_to_string(10, 5, &jobs);
}
#[test]
fn test_render_empty_joblist() {
let mut jobs = JobList::new();
jobs.set_index(0).unwrap();
let content = render_to_string(80, 20, &jobs);
assert!(content.contains("SLURM TASK MANAGER"));
assert!(content.contains("No jobs found"));
}
#[test]
fn test_render_pending_job_shows_reason_explanation() {
let mut job = make_running_job();
job.status = JobStatus::Pending;
job.reason = Some("Priority".to_string());
let mut jobs = JobList::new();
jobs.jobs.push(job);
jobs.set_index(0).unwrap();
let content = render_with_details(100, 24, &jobs);
assert!(content.contains("Pending — Priority:"));
assert!(content.contains("higher-priority jobs are ahead in the queue"));
}
#[test]
fn test_render_pending_job_with_unknown_reason_shows_raw_code() {
let mut job = make_running_job();
job.status = JobStatus::Pending;
job.reason = Some("SomeNewSlurmReason".to_string());
let mut jobs = JobList::new();
jobs.jobs.push(job);
jobs.set_index(0).unwrap();
let content = render_with_details(100, 24, &jobs);
assert!(content.contains("Pending — SomeNewSlurmReason"));
}
#[test]
fn test_render_running_job_with_stats_shows_gauges() {
let mut job = make_running_job();
job.stats = Some(Box::new(crate::job::JobStats {
mem_efficiency: Some(0.42),
elapsed_frac_of_limit: Some(0.61),
}));
let mut jobs = JobList::new();
jobs.jobs.push(job);
jobs.set_index(0).unwrap();
let content = render_with_details(100, 24, &jobs);
assert!(content.contains("Efficiency"));
assert!(!content.contains("CPU"));
assert!(content.contains("Mem"));
assert!(content.contains("42%"));
assert!(content.contains("Time"));
assert!(content.contains("61%"));
assert!(content.contains("████"));
}
#[test]
fn test_render_running_job_without_stats_shows_no_gauges() {
let mut jobs = JobList::new();
jobs.jobs.push(make_running_job());
jobs.set_index(0).unwrap();
let content = render_with_details(100, 24, &jobs);
assert!(!content.contains("Efficiency"));
assert!(!content.contains("Pending —"));
}
#[test]
fn test_bar_string_widths_and_clamping() {
assert_eq!(bar_string(0.0, 4), "░░░░");
assert_eq!(bar_string(1.0, 4), "████");
assert_eq!(bar_string(-0.5, 4), "░░░░");
assert_eq!(bar_string(2.5, 4), "████");
assert_eq!(bar_string(0.5, 4), "██░░");
assert_eq!(bar_string(0.625, 4), "██▌░");
for i in 0..=10 {
let bar = bar_string(i as f64 / 10.0, 7);
assert_eq!(bar.chars().count(), 7, "wrong width for {}", i);
}
}
#[test]
fn test_efficiency_colors() {
assert_eq!(utilization_color(0.1), Color::Red);
assert_eq!(utilization_color(0.45), Color::Yellow);
assert_eq!(utilization_color(0.9), Color::Green);
assert_eq!(time_limit_color(0.5), Color::Green);
assert_eq!(time_limit_color(0.8), Color::Yellow);
assert_eq!(time_limit_color(0.95), Color::Red);
}
fn snapshot_terminal(
jobs: &JobList,
collapsed_top: bool,
collapsed_bot: bool,
) -> Terminal<TestBackend> {
let backend = TestBackend::new(100, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut overview = JobOverview::new(250, "squeue -u user", JobColumn::defaults());
overview.collapsed_top = collapsed_top;
overview.collapsed_bot = collapsed_bot;
terminal
.draw(|f| {
let area = f.area();
overview.render(f, &area, jobs);
})
.unwrap();
terminal
}
fn make_snapshot_joblist() -> JobList {
let mut jobs = JobList::new();
jobs.set_group_job_arrays(true);
jobs.jobs.push(make_running_job());
let mut pending = make_running_job();
pending.id = "424243".to_string();
pending.name = "preprocess".to_string();
pending.status = JobStatus::Pending;
pending.time = "0:00".to_string();
pending.nodes = 1;
jobs.jobs.push(pending);
let mut done = make_running_job();
done.id = "424241".to_string();
done.name = "download_data".to_string();
done.status = JobStatus::Completed;
done.time = "1:23:45".to_string();
done.partition = "cpu".to_string();
done.nodes = 1;
jobs.jobs.push(done);
jobs.set_index(0).unwrap();
jobs
}
#[test]
fn test_snapshot_collapsed_layout() {
let jobs = make_snapshot_joblist();
let terminal = snapshot_terminal(&jobs, true, true);
insta::assert_snapshot!("collapsed_layout", terminal.backend());
}
#[test]
fn test_snapshot_extended_layout() {
let jobs = make_snapshot_joblist();
let terminal = snapshot_terminal(&jobs, false, false);
insta::assert_snapshot!("extended_layout", terminal.backend());
}
#[test]
fn test_snapshot_empty_joblist() {
let mut jobs = JobList::new();
jobs.set_group_job_arrays(true);
jobs.set_index(0).unwrap();
let terminal = snapshot_terminal(&jobs, false, true);
insta::assert_snapshot!("empty_joblist", terminal.backend());
}
#[test]
fn test_snapshot_pending_job_with_reason() {
let mut jobs = make_snapshot_joblist();
jobs.jobs[1].reason = Some("Priority".to_string());
jobs.set_index(1).unwrap();
let terminal = snapshot_terminal(&jobs, false, false);
insta::assert_snapshot!("pending_job_with_reason", terminal.backend());
}
#[test]
fn test_snapshot_running_job_with_stats() {
let mut jobs = make_snapshot_joblist();
jobs.jobs[0].stats = Some(Box::new(crate::job::JobStats {
mem_efficiency: Some(0.42),
elapsed_frac_of_limit: Some(0.61),
}));
jobs.set_index(0).unwrap();
let terminal = snapshot_terminal(&jobs, false, false);
insta::assert_snapshot!("running_job_with_stats", terminal.backend());
}
#[test]
fn test_snapshot_filtered_joblist() {
let mut jobs = make_snapshot_joblist();
jobs.set_filter("train", &JobColumn::defaults());
let terminal = snapshot_terminal(&jobs, false, true);
insta::assert_snapshot!("filtered_joblist", terminal.backend());
}
#[test]
fn test_snapshot_array_group_collapsed() {
let mut jobs = make_array_joblist();
jobs.set_group_job_arrays(true);
let terminal = snapshot_terminal(&jobs, false, true);
insta::assert_snapshot!("array_group_collapsed", terminal.backend());
}
#[test]
fn test_snapshot_array_group_expanded() {
let mut jobs = make_array_joblist();
jobs.set_group_job_arrays(true);
jobs.handle_joblist_action(JobListAction::ToggleGroup, &JobColumn::defaults());
let terminal = snapshot_terminal(&jobs, false, true);
insta::assert_snapshot!("array_group_expanded", terminal.backend());
}
}