Skip to content

Commit 7bfbe72

Browse files
feat: add run management and snakemake job filtering
- Add new charmer-runs crate to track pipeline runs persistently - Stores runs in .snakemake/charmer/runs.json per project - Tracks run UUID, status, job counts, and timestamps - Auto-selects current/most recent run on startup - Filter non-snakemake jobs by default - Add is_snakemake_job field to Job struct - Jobs identified by rule_ prefix in SLURM/LSF comment - Interactive jobs and other non-snakemake jobs now hidden - Add CLI flags - --all-jobs: Show all SLURM jobs, not just snakemake - --list-runs: List recent runs and exit - Add TUI features - Run picker modal (R key) to browse previous runs - Toggle all-jobs view (a key) - Header shows current run UUID - Updated footer and help with new key bindings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 966a6e4 commit 7bfbe72

File tree

16 files changed

+597
-12
lines changed

16 files changed

+597
-12
lines changed

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ authors = ["Jay Hesselberth"]
1111

1212
[workspace.dependencies]
1313
# TUI
14-
ratatui = "0.30.0-beta.0"
14+
ratatui = "0.30"
1515
crossterm = "0.29"
1616

1717
# Async
@@ -58,3 +58,4 @@ charmer-lsf = { path = "crates/charmer-lsf" }
5858
charmer-state = { path = "crates/charmer-state" }
5959
charmer-monitor = { path = "crates/charmer-monitor" }
6060
charmer-cli = { path = "crates/charmer-cli" }
61+
charmer-runs = { path = "crates/charmer-runs" }

crates/charmer-cli/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,12 @@ pub struct Args {
2626
/// Show completed jobs from last N hours
2727
#[arg(long, default_value = "24")]
2828
pub history_hours: u64,
29+
30+
/// Show all SLURM jobs, not just snakemake jobs
31+
#[arg(long, default_value = "false")]
32+
pub all_jobs: bool,
33+
34+
/// List recent runs and exit
35+
#[arg(long)]
36+
pub list_runs: bool,
2937
}

crates/charmer-monitor/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ license.workspace = true
66

77
[dependencies]
88
charmer-state.workspace = true
9+
charmer-runs.workspace = true
910
ratatui.workspace = true
1011
crossterm.workspace = true
1112
tokio.workspace = true

crates/charmer-monitor/src/app.rs

Lines changed: 199 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::components::{
44
Footer, Header, JobDetail, JobList, LogViewer, LogViewerState, RuleSummary,
55
};
66
use crate::ui::Theme;
7+
use charmer_runs::RunInfo;
78
use charmer_state::{JobStatus, PipelineState, MAIN_PIPELINE_JOB_ID};
89
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
910
use ratatui::{
@@ -110,10 +111,27 @@ pub struct App {
110111
rule_names: Vec<String>, // Cached rule names for rule view
111112
status_message: Option<(String, Instant)>, // Temporary status message with timestamp
112113
command_expanded: bool, // Whether command section is expanded in details
114+
115+
// Run management
116+
pub runs: Vec<RunInfo>, // Available runs
117+
pub selected_run: Option<String>, // Currently selected run UUID
118+
pub show_run_picker: bool, // Whether run picker modal is open
119+
pub run_picker_index: usize, // Selected index in run picker
120+
pub show_all_jobs: bool, // Whether to show all jobs or just snakemake jobs
113121
}
114122

115123
impl App {
116124
pub fn new(state: PipelineState) -> Self {
125+
Self::with_options(state, false, Vec::new(), None)
126+
}
127+
128+
/// Create a new App with run management options.
129+
pub fn with_options(
130+
state: PipelineState,
131+
show_all_jobs: bool,
132+
runs: Vec<RunInfo>,
133+
selected_run: Option<String>,
134+
) -> Self {
117135
let job_ids = state.jobs.keys().cloned().collect();
118136
let rule_names: Vec<String> = state.jobs_by_rule.keys().cloned().collect();
119137
let mut app = Self {
@@ -132,6 +150,11 @@ impl App {
132150
rule_names,
133151
status_message: None,
134152
command_expanded: false,
153+
runs,
154+
selected_run,
155+
show_run_picker: false,
156+
run_picker_index: 0,
157+
show_all_jobs,
135158
};
136159
// Update job list first to ensure MAIN_PIPELINE_JOB_ID is in the list
137160
app.update_job_list();
@@ -146,7 +169,13 @@ impl App {
146169
.state
147170
.jobs
148171
.iter()
149-
.filter(|(_, job)| self.filter_mode.matches(job.status))
172+
.filter(|(_, job)| {
173+
// Filter by snakemake status if not showing all jobs
174+
if !self.show_all_jobs && !job.is_snakemake_job {
175+
return false;
176+
}
177+
self.filter_mode.matches(job.status)
178+
})
150179
.collect();
151180

152181
// Sort jobs
@@ -298,6 +327,31 @@ impl App {
298327
self.show_help = !self.show_help;
299328
}
300329

330+
/// Toggle run picker modal.
331+
pub fn toggle_run_picker(&mut self) {
332+
self.show_run_picker = !self.show_run_picker;
333+
if self.show_run_picker {
334+
self.run_picker_index = 0;
335+
}
336+
}
337+
338+
/// Toggle between snakemake-only and all-jobs view.
339+
pub fn toggle_all_jobs(&mut self) {
340+
self.show_all_jobs = !self.show_all_jobs;
341+
self.update_job_list();
342+
let msg = if self.show_all_jobs {
343+
"Showing all jobs"
344+
} else {
345+
"Showing snakemake jobs only"
346+
};
347+
self.status_message = Some((msg.to_string(), Instant::now()));
348+
}
349+
350+
/// Update runs list.
351+
pub fn update_runs(&mut self, runs: Vec<RunInfo>) {
352+
self.runs = runs;
353+
}
354+
301355
/// Toggle between jobs and rules view.
302356
pub fn toggle_view_mode(&mut self) {
303357
self.view_mode = match self.view_mode {
@@ -552,6 +606,38 @@ impl App {
552606
return;
553607
}
554608

609+
// If run picker is showing, handle picker navigation
610+
if self.show_run_picker {
611+
match key.code {
612+
KeyCode::Esc | KeyCode::Char('q') => self.show_run_picker = false,
613+
KeyCode::Char('j') | KeyCode::Down => {
614+
if !self.runs.is_empty() {
615+
self.run_picker_index = (self.run_picker_index + 1) % self.runs.len();
616+
}
617+
}
618+
KeyCode::Char('k') | KeyCode::Up => {
619+
if !self.runs.is_empty() {
620+
self.run_picker_index = self
621+
.run_picker_index
622+
.checked_sub(1)
623+
.unwrap_or(self.runs.len() - 1);
624+
}
625+
}
626+
KeyCode::Enter => {
627+
if let Some(run) = self.runs.get(self.run_picker_index) {
628+
self.selected_run = Some(run.run_uuid.clone());
629+
self.status_message = Some((
630+
format!("Selected run: {}", &run.run_uuid[..8.min(run.run_uuid.len())]),
631+
Instant::now(),
632+
));
633+
}
634+
self.show_run_picker = false;
635+
}
636+
_ => {}
637+
}
638+
return;
639+
}
640+
555641
match key.code {
556642
KeyCode::Char('q') => self.quit(),
557643
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => self.quit(),
@@ -574,6 +660,8 @@ impl App {
574660
KeyCode::Char('f') => self.cycle_filter(),
575661
KeyCode::Char('s') => self.cycle_sort(),
576662
KeyCode::Char('r') => self.toggle_view_mode(),
663+
KeyCode::Char('R') => self.toggle_run_picker(),
664+
KeyCode::Char('a') => self.toggle_all_jobs(),
577665
KeyCode::Char('l') | KeyCode::Enter => self.toggle_log_viewer(),
578666
KeyCode::Char('F') if self.show_log_viewer => {
579667
// Toggle follow mode when log panel is open
@@ -693,10 +781,101 @@ impl App {
693781
// Footer with optional status message
694782
Footer::render(frame, chunks[3], status_msg);
695783

696-
// Help overlay (on top of everything)
784+
// Overlays (on top of everything)
697785
if self.show_help {
698786
self.render_help_overlay(frame);
699787
}
788+
if self.show_run_picker {
789+
self.render_run_picker(frame);
790+
}
791+
}
792+
793+
/// Render run picker modal.
794+
fn render_run_picker(&self, frame: &mut Frame) {
795+
use ratatui::style::{Color, Modifier, Style};
796+
use ratatui::text::{Line, Span};
797+
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
798+
799+
let area = centered_rect(60, 50, frame.area());
800+
frame.render_widget(Clear, area);
801+
802+
if self.runs.is_empty() {
803+
let paragraph = Paragraph::new("No runs found")
804+
.block(
805+
Block::default()
806+
.borders(Borders::ALL)
807+
.title(" Select Run (Esc to close) ")
808+
.style(Style::default().bg(Color::DarkGray)),
809+
)
810+
.style(Style::default().fg(Color::White).bg(Color::DarkGray));
811+
frame.render_widget(paragraph, area);
812+
return;
813+
}
814+
815+
let items: Vec<ListItem> = self
816+
.runs
817+
.iter()
818+
.map(|run| {
819+
let status_icon = match run.status {
820+
charmer_runs::RunStatus::Running => "●",
821+
charmer_runs::RunStatus::Completed => "✓",
822+
charmer_runs::RunStatus::Failed => "✗",
823+
charmer_runs::RunStatus::Unknown => "?",
824+
};
825+
826+
let status_color = match run.status {
827+
charmer_runs::RunStatus::Running => Color::Yellow,
828+
charmer_runs::RunStatus::Completed => Color::Green,
829+
charmer_runs::RunStatus::Failed => Color::Red,
830+
charmer_runs::RunStatus::Unknown => Color::Gray,
831+
};
832+
833+
let uuid_short = if run.run_uuid.len() > 12 {
834+
&run.run_uuid[..12]
835+
} else {
836+
&run.run_uuid
837+
};
838+
839+
let jobs_str = format!(
840+
"{}/{}",
841+
run.completed_jobs,
842+
run.total_jobs.unwrap_or(0)
843+
);
844+
845+
let time_ago = format_time_ago(run.last_updated);
846+
847+
let line = Line::from(vec![
848+
Span::styled(status_icon, Style::default().fg(status_color)),
849+
Span::raw(" "),
850+
Span::styled(uuid_short, Style::default().fg(Color::Cyan)),
851+
Span::raw(" - "),
852+
Span::styled(jobs_str, Style::default().fg(Color::White)),
853+
Span::raw(" jobs - "),
854+
Span::styled(time_ago, Style::default().fg(Color::Gray)),
855+
]);
856+
857+
ListItem::new(line)
858+
})
859+
.collect();
860+
861+
let mut state = ListState::default();
862+
state.select(Some(self.run_picker_index));
863+
864+
let list = List::new(items)
865+
.block(
866+
Block::default()
867+
.borders(Borders::ALL)
868+
.title(" Select Run (Enter to select, Esc to cancel) ")
869+
.style(Style::default().bg(Color::DarkGray)),
870+
)
871+
.highlight_style(
872+
Style::default()
873+
.bg(Color::Rgb(60, 60, 80))
874+
.add_modifier(Modifier::BOLD),
875+
)
876+
.style(Style::default().bg(Color::DarkGray));
877+
878+
frame.render_stateful_widget(list, area, &mut state);
700879
}
701880

702881
/// Render detail panel for selected rule.
@@ -919,7 +1098,8 @@ impl App {
9191098
g / Home Go to first item
9201099
G / End Go to last item
9211100
r Toggle view (Jobs/Rules summary)
922-
d Toggle DAG view
1101+
R Open run selector
1102+
a Toggle all jobs / snakemake only
9231103
f Cycle filter (All/Running/Failed/Pending/Completed)
9241104
s Cycle sort (Status/Rule/Time)
9251105
l / Enter Toggle log panel
@@ -981,3 +1161,19 @@ fn format_secs(secs: u64) -> String {
9811161
format!("{}s", secs)
9821162
}
9831163
}
1164+
1165+
/// Format a timestamp as a human-readable "time ago" string.
1166+
fn format_time_ago(dt: chrono::DateTime<chrono::Utc>) -> String {
1167+
let now = chrono::Utc::now();
1168+
let duration = now.signed_duration_since(dt);
1169+
1170+
if duration.num_days() > 0 {
1171+
format!("{}d ago", duration.num_days())
1172+
} else if duration.num_hours() > 0 {
1173+
format!("{}h ago", duration.num_hours())
1174+
} else if duration.num_minutes() > 0 {
1175+
format!("{}m ago", duration.num_minutes())
1176+
} else {
1177+
"just now".to_string()
1178+
}
1179+
}

crates/charmer-monitor/src/components/footer.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub struct Footer;
1515

1616
impl Footer {
1717
pub fn render(frame: &mut Frame, area: Rect, status_message: Option<&str>) {
18-
let help = "j/k:navigate l:logs r:rules f:filter s:sort ?:help q:quit";
18+
let help = "j/k:nav R:runs a:all l:logs r:rules f:filter s:sort ?:help q:quit";
1919
let version = format!("v{}", VERSION);
2020

2121
// Split footer into left (help/status), right (version)

crates/charmer-monitor/src/components/header.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,20 @@ impl Header {
5656
));
5757
}
5858

59+
// Run UUID (if available)
60+
if let Some(ref run_uuid) = state.run_uuid {
61+
let uuid_short = if run_uuid.len() > 8 {
62+
format!("{}…", &run_uuid[..8])
63+
} else {
64+
run_uuid.clone()
65+
};
66+
spans.push(sep.clone());
67+
spans.push(Span::styled(
68+
format!("[{}]", uuid_short),
69+
Style::default().fg(Color::Magenta),
70+
));
71+
}
72+
5973
spans.push(sep.clone());
6074

6175
// Working directory

crates/charmer-runs/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "charmer-runs"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
authors.workspace = true
7+
8+
[dependencies]
9+
serde.workspace = true
10+
serde_json.workspace = true
11+
chrono.workspace = true
12+
camino.workspace = true
13+
thiserror.workspace = true
14+
15+
[dev-dependencies]
16+
tempfile = "3"

crates/charmer-runs/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pub mod store;
2+
pub mod types;
3+
4+
pub use store::{RunStore, StoreError};
5+
pub use types::{RunInfo, RunStatus, RunsState};

0 commit comments

Comments
 (0)