feat: add ecc2 output time filters

This commit is contained in:
Affaan Mustafa
2026-04-09 04:10:51 -07:00
parent 077f46b777
commit 3b700c8715
4 changed files with 230 additions and 70 deletions

View File

@@ -32,6 +32,31 @@ impl OutputStream {
pub struct OutputLine { pub struct OutputLine {
pub stream: OutputStream, pub stream: OutputStream,
pub text: String, pub text: String,
pub timestamp: String,
}
impl OutputLine {
pub fn new(
stream: OutputStream,
text: impl Into<String>,
timestamp: impl Into<String>,
) -> Self {
Self {
stream,
text: text.into(),
timestamp: timestamp.into(),
}
}
pub fn with_current_timestamp(stream: OutputStream, text: impl Into<String>) -> Self {
Self::new(stream, text, chrono::Utc::now().to_rfc3339())
}
pub fn occurred_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(&self.timestamp)
.ok()
.map(|timestamp| timestamp.with_timezone(&chrono::Utc))
}
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -70,10 +95,7 @@ impl SessionOutputStore {
} }
pub fn push_line(&self, session_id: &str, stream: OutputStream, text: impl Into<String>) { pub fn push_line(&self, session_id: &str, stream: OutputStream, text: impl Into<String>) {
let line = OutputLine { let line = OutputLine::with_current_timestamp(stream, text);
stream,
text: text.into(),
};
{ {
let mut buffers = self.lock_buffers(); let mut buffers = self.lock_buffers();
@@ -145,5 +167,6 @@ mod tests {
assert_eq!(event.session_id, "session-1"); assert_eq!(event.session_id, "session-1");
assert_eq!(event.line.stream, OutputStream::Stderr); assert_eq!(event.line.stream, OutputStream::Stderr);
assert_eq!(event.line.text, "problem"); assert_eq!(event.line.text, "problem");
assert!(event.line.occurred_at().is_some());
} }
} }

View File

@@ -961,9 +961,9 @@ impl StateStore {
pub fn get_output_lines(&self, session_id: &str, limit: usize) -> Result<Vec<OutputLine>> { pub fn get_output_lines(&self, session_id: &str, limit: usize) -> Result<Vec<OutputLine>> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT stream, line "SELECT stream, line, timestamp
FROM ( FROM (
SELECT id, stream, line SELECT id, stream, line, timestamp
FROM session_output FROM session_output
WHERE session_id = ?1 WHERE session_id = ?1
ORDER BY id DESC ORDER BY id DESC
@@ -976,11 +976,13 @@ impl StateStore {
.query_map(rusqlite::params![session_id, limit as i64], |row| { .query_map(rusqlite::params![session_id, limit as i64], |row| {
let stream: String = row.get(0)?; let stream: String = row.get(0)?;
let text: String = row.get(1)?; let text: String = row.get(1)?;
let timestamp: String = row.get(2)?;
Ok(OutputLine { Ok(OutputLine::new(
stream: OutputStream::from_db_value(&stream), OutputStream::from_db_value(&stream),
text, text,
}) timestamp,
))
})? })?
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;

View File

@@ -74,6 +74,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
(_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(),
(_, KeyCode::Char('e')) => dashboard.toggle_output_filter(), (_, KeyCode::Char('e')) => dashboard.toggle_output_filter(),
(_, KeyCode::Char('f')) => dashboard.cycle_output_time_filter(),
(_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await, (_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await,
(_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await, (_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await,
(_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(), (_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(),

View File

@@ -1,3 +1,4 @@
use chrono::{Duration, Utc};
use ratatui::{ use ratatui::{
prelude::*, prelude::*,
widgets::{ widgets::{
@@ -72,6 +73,7 @@ pub struct Dashboard {
selected_merge_readiness: Option<worktree::MergeReadiness>, selected_merge_readiness: Option<worktree::MergeReadiness>,
output_mode: OutputMode, output_mode: OutputMode,
output_filter: OutputFilter, output_filter: OutputFilter,
output_time_filter: OutputTimeFilter,
selected_pane: Pane, selected_pane: Pane,
selected_session: usize, selected_session: usize,
show_help: bool, show_help: bool,
@@ -123,6 +125,14 @@ enum OutputFilter {
ErrorsOnly, ErrorsOnly,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputTimeFilter {
AllTime,
Last15Minutes,
LastHour,
Last24Hours,
}
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
struct PaneAreas { struct PaneAreas {
sessions: Rect, sessions: Rect,
@@ -201,6 +211,7 @@ impl Dashboard {
selected_merge_readiness: None, selected_merge_readiness: None,
output_mode: OutputMode::SessionOutput, output_mode: OutputMode::SessionOutput,
output_filter: OutputFilter::All, output_filter: OutputFilter::All,
output_time_filter: OutputTimeFilter::AllTime,
selected_pane: Pane::Sessions, selected_pane: Pane::Sessions,
selected_session: 0, selected_session: 0,
show_help: false, show_help: false,
@@ -472,7 +483,11 @@ impl Dashboard {
} }
fn output_title(&self) -> String { fn output_title(&self) -> String {
let filter = self.output_filter_label(); let filter = format!(
"{}{}",
self.output_filter.title_suffix(),
self.output_time_filter.title_suffix()
);
if let Some(input) = self.search_input.as_ref() { if let Some(input) = self.search_input.as_ref() {
return format!(" Output{filter} /{input}_ "); return format!(" Output{filter} /{input}_ ");
} }
@@ -490,17 +505,14 @@ impl Dashboard {
format!(" Output{filter} ") format!(" Output{filter} ")
} }
fn output_filter_label(&self) -> &'static str {
match self.output_filter {
OutputFilter::All => "",
OutputFilter::ErrorsOnly => " errors",
}
}
fn empty_output_message(&self) -> &'static str { fn empty_output_message(&self) -> &'static str {
match self.output_filter { match (self.output_filter, self.output_time_filter) {
OutputFilter::All => "Waiting for session output...", (OutputFilter::All, OutputTimeFilter::AllTime) => "Waiting for session output...",
OutputFilter::ErrorsOnly => "No stderr output for this session yet.", (OutputFilter::ErrorsOnly, OutputTimeFilter::AllTime) => {
"No stderr output for this session yet."
}
(OutputFilter::All, _) => "No output lines in the selected time range.",
(OutputFilter::ErrorsOnly, _) => "No stderr output in the selected time range.",
} }
} }
@@ -611,7 +623,7 @@ impl Dashboard {
fn render_status_bar(&self, frame: &mut Frame, area: Rect) { fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
let base_text = format!( let base_text = format!(
" [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol [e]rrors [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", " [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol [e]rrors time [f]ilter [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ",
self.layout_label(), self.layout_label(),
self.theme_label() self.theme_label()
); );
@@ -683,6 +695,7 @@ impl Dashboard {
" v Toggle selected worktree diff in output pane", " v Toggle selected worktree diff in output pane",
" c Show conflict-resolution protocol for selected conflicted worktree", " c Show conflict-resolution protocol for selected conflicted worktree",
" e Toggle output filter between all lines and stderr only", " e Toggle output filter between all lines and stderr only",
" f Cycle output time filter between all/15m/1h/24h",
" m Merge selected ready worktree into base and clean it up", " m Merge selected ready worktree into base and clean it up",
" M Merge all ready inactive worktrees and clean them up", " M Merge all ready inactive worktrees and clean them up",
" l Cycle pane layout and persist it", " l Cycle pane layout and persist it",
@@ -1724,6 +1737,23 @@ impl Dashboard {
)); ));
} }
pub fn cycle_output_time_filter(&mut self) {
if self.output_mode != OutputMode::SessionOutput {
self.set_operator_note(
"output time filters are only available in session output view".to_string(),
);
return;
}
self.output_time_filter = self.output_time_filter.next();
self.recompute_search_matches();
self.sync_output_scroll(self.last_output_height.max(1));
self.set_operator_note(format!(
"output time filter set to {}",
self.output_time_filter.label()
));
}
pub fn toggle_auto_dispatch_policy(&mut self) { pub fn toggle_auto_dispatch_policy(&mut self) {
self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs; self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs;
match self.cfg.save() { match self.cfg.save() {
@@ -2192,7 +2222,9 @@ impl Dashboard {
fn visible_output_lines(&self) -> Vec<&OutputLine> { fn visible_output_lines(&self) -> Vec<&OutputLine> {
self.selected_output_lines() self.selected_output_lines()
.iter() .iter()
.filter(|line| self.output_filter.matches(line.stream)) .filter(|line| {
self.output_filter.matches(line.stream) && self.output_time_filter.matches(line)
})
.collect() .collect()
} }
@@ -2864,6 +2896,60 @@ impl OutputFilter {
OutputFilter::ErrorsOnly => "errors", OutputFilter::ErrorsOnly => "errors",
} }
} }
fn title_suffix(self) -> &'static str {
match self {
OutputFilter::All => "",
OutputFilter::ErrorsOnly => " errors",
}
}
}
impl OutputTimeFilter {
fn next(self) -> Self {
match self {
Self::AllTime => Self::Last15Minutes,
Self::Last15Minutes => Self::LastHour,
Self::LastHour => Self::Last24Hours,
Self::Last24Hours => Self::AllTime,
}
}
fn matches(self, line: &OutputLine) -> bool {
match self {
Self::AllTime => true,
Self::Last15Minutes => line
.occurred_at()
.map(|timestamp| timestamp >= Utc::now() - Duration::minutes(15))
.unwrap_or(false),
Self::LastHour => line
.occurred_at()
.map(|timestamp| timestamp >= Utc::now() - Duration::hours(1))
.unwrap_or(false),
Self::Last24Hours => line
.occurred_at()
.map(|timestamp| timestamp >= Utc::now() - Duration::hours(24))
.unwrap_or(false),
}
}
fn label(self) -> &'static str {
match self {
Self::AllTime => "all time",
Self::Last15Minutes => "last 15m",
Self::LastHour => "last 1h",
Self::Last24Hours => "last 24h",
}
}
fn title_suffix(self) -> &'static str {
match self {
Self::AllTime => "",
Self::Last15Minutes => " last 15m",
Self::LastHour => " last 1h",
Self::Last24Hours => " last 24h",
}
}
} }
impl SessionSummary { impl SessionSummary {
@@ -3320,10 +3406,7 @@ mod tests {
); );
dashboard.session_output_cache.insert( dashboard.session_output_cache.insert(
"focus-12345678".to_string(), "focus-12345678".to_string(),
vec![OutputLine { vec![test_output_line(OutputStream::Stdout, "last useful output")],
stream: OutputStream::Stdout,
text: "last useful output".to_string(),
}],
); );
dashboard.selected_diff_summary = Some("1 file changed, 2 insertions(+)".to_string()); dashboard.selected_diff_summary = Some("1 file changed, 2 insertions(+)".to_string());
dashboard.selected_diff_preview = vec![ dashboard.selected_diff_preview = vec![
@@ -4160,18 +4243,9 @@ diff --git a/src/next.rs b/src/next.rs
dashboard.session_output_cache.insert( dashboard.session_output_cache.insert(
"focus-12345678".to_string(), "focus-12345678".to_string(),
vec![ vec![
OutputLine { test_output_line(OutputStream::Stdout, "alpha"),
stream: OutputStream::Stdout, test_output_line(OutputStream::Stdout, "beta"),
text: "alpha".to_string(), test_output_line(OutputStream::Stdout, "alpha tail"),
},
OutputLine {
stream: OutputStream::Stdout,
text: "beta".to_string(),
},
OutputLine {
stream: OutputStream::Stdout,
text: "alpha tail".to_string(),
},
], ],
); );
dashboard.last_output_height = 2; dashboard.last_output_height = 2;
@@ -4207,18 +4281,9 @@ diff --git a/src/next.rs b/src/next.rs
dashboard.session_output_cache.insert( dashboard.session_output_cache.insert(
"focus-12345678".to_string(), "focus-12345678".to_string(),
vec![ vec![
OutputLine { test_output_line(OutputStream::Stdout, "alpha-1"),
stream: OutputStream::Stdout, test_output_line(OutputStream::Stdout, "beta"),
text: "alpha-1".to_string(), test_output_line(OutputStream::Stdout, "alpha-2"),
},
OutputLine {
stream: OutputStream::Stdout,
text: "beta".to_string(),
},
OutputLine {
stream: OutputStream::Stdout,
text: "alpha-2".to_string(),
},
], ],
); );
dashboard.search_query = Some(r"alpha-\d".to_string()); dashboard.search_query = Some(r"alpha-\d".to_string());
@@ -4304,14 +4369,8 @@ diff --git a/src/next.rs b/src/next.rs
dashboard.session_output_cache.insert( dashboard.session_output_cache.insert(
"focus-12345678".to_string(), "focus-12345678".to_string(),
vec![ vec![
OutputLine { test_output_line(OutputStream::Stdout, "stdout line"),
stream: OutputStream::Stdout, test_output_line(OutputStream::Stderr, "stderr line"),
text: "stdout line".to_string(),
},
OutputLine {
stream: OutputStream::Stderr,
text: "stderr line".to_string(),
},
], ],
); );
@@ -4342,18 +4401,9 @@ diff --git a/src/next.rs b/src/next.rs
dashboard.session_output_cache.insert( dashboard.session_output_cache.insert(
"focus-12345678".to_string(), "focus-12345678".to_string(),
vec![ vec![
OutputLine { test_output_line(OutputStream::Stdout, "alpha stdout"),
stream: OutputStream::Stdout, test_output_line(OutputStream::Stderr, "alpha stderr"),
text: "alpha stdout".to_string(), test_output_line(OutputStream::Stderr, "beta stderr"),
},
OutputLine {
stream: OutputStream::Stderr,
text: "alpha stderr".to_string(),
},
OutputLine {
stream: OutputStream::Stderr,
text: "beta stderr".to_string(),
},
], ],
); );
dashboard.output_filter = OutputFilter::ErrorsOnly; dashboard.output_filter = OutputFilter::ErrorsOnly;
@@ -4366,6 +4416,73 @@ diff --git a/src/next.rs b/src/next.rs
assert_eq!(dashboard.visible_output_text(), "alpha stderr\nbeta stderr"); assert_eq!(dashboard.visible_output_text(), "alpha stderr\nbeta stderr");
} }
#[test]
fn cycle_output_time_filter_keeps_only_recent_lines() {
let mut dashboard = test_dashboard(
vec![sample_session(
"focus-12345678",
"planner",
SessionState::Running,
None,
1,
1,
)],
0,
);
dashboard.session_output_cache.insert(
"focus-12345678".to_string(),
vec![
test_output_line_minutes_ago(OutputStream::Stdout, "recent line", 5),
test_output_line_minutes_ago(OutputStream::Stdout, "older line", 45),
test_output_line_minutes_ago(OutputStream::Stdout, "stale line", 180),
],
);
dashboard.cycle_output_time_filter();
assert_eq!(
dashboard.output_time_filter,
OutputTimeFilter::Last15Minutes
);
assert_eq!(dashboard.visible_output_text(), "recent line");
assert_eq!(dashboard.output_title(), " Output last 15m ");
assert_eq!(
dashboard.operator_note.as_deref(),
Some("output time filter set to last 15m")
);
}
#[test]
fn search_matches_respect_time_filter() {
let mut dashboard = test_dashboard(
vec![sample_session(
"focus-12345678",
"planner",
SessionState::Running,
None,
1,
1,
)],
0,
);
dashboard.session_output_cache.insert(
"focus-12345678".to_string(),
vec![
test_output_line_minutes_ago(OutputStream::Stdout, "alpha recent", 10),
test_output_line_minutes_ago(OutputStream::Stdout, "beta recent", 10),
test_output_line_minutes_ago(OutputStream::Stdout, "alpha stale", 180),
],
);
dashboard.output_time_filter = OutputTimeFilter::Last15Minutes;
dashboard.search_query = Some("alpha.*".to_string());
dashboard.last_output_height = 1;
dashboard.recompute_search_matches();
assert_eq!(dashboard.search_matches, vec![0]);
assert_eq!(dashboard.visible_output_text(), "alpha recent\nbeta recent");
}
#[tokio::test] #[tokio::test]
async fn stop_selected_uses_session_manager_transition() -> Result<()> { async fn stop_selected_uses_session_manager_transition() -> Result<()> {
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
@@ -5056,6 +5173,22 @@ diff --git a/src/next.rs b/src/next.rs
assert_eq!(dashboard.theme_palette().row_highlight_bg, Color::Gray); assert_eq!(dashboard.theme_palette().row_highlight_bg, Color::Gray);
} }
fn test_output_line(stream: OutputStream, text: &str) -> OutputLine {
OutputLine::new(stream, text, Utc::now().to_rfc3339())
}
fn test_output_line_minutes_ago(
stream: OutputStream,
text: &str,
minutes_ago: i64,
) -> OutputLine {
OutputLine::new(
stream,
text,
(Utc::now() - chrono::Duration::minutes(minutes_ago)).to_rfc3339(),
)
}
fn test_dashboard(sessions: Vec<Session>, selected_session: usize) -> Dashboard { fn test_dashboard(sessions: Vec<Session>, selected_session: usize) -> Dashboard {
let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let selected_session = selected_session.min(sessions.len().saturating_sub(1));
let cfg = Config::default(); let cfg = Config::default();
@@ -5093,6 +5226,7 @@ diff --git a/src/next.rs b/src/next.rs
selected_merge_readiness: None, selected_merge_readiness: None,
output_mode: OutputMode::SessionOutput, output_mode: OutputMode::SessionOutput,
output_filter: OutputFilter::All, output_filter: OutputFilter::All,
output_time_filter: OutputTimeFilter::AllTime,
selected_pane: Pane::Sessions, selected_pane: Pane::Sessions,
selected_session, selected_session,
show_help: false, show_help: false,