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 stream: OutputStream,
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)]
@@ -70,10 +95,7 @@ impl SessionOutputStore {
}
pub fn push_line(&self, session_id: &str, stream: OutputStream, text: impl Into<String>) {
let line = OutputLine {
stream,
text: text.into(),
};
let line = OutputLine::with_current_timestamp(stream, text);
{
let mut buffers = self.lock_buffers();
@@ -145,5 +167,6 @@ mod tests {
assert_eq!(event.session_id, "session-1");
assert_eq!(event.line.stream, OutputStream::Stderr);
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>> {
let mut stmt = self.conn.prepare(
"SELECT stream, line
"SELECT stream, line, timestamp
FROM (
SELECT id, stream, line
SELECT id, stream, line, timestamp
FROM session_output
WHERE session_id = ?1
ORDER BY id DESC
@@ -976,11 +976,13 @@ impl StateStore {
.query_map(rusqlite::params![session_id, limit as i64], |row| {
let stream: String = row.get(0)?;
let text: String = row.get(1)?;
let timestamp: String = row.get(2)?;
Ok(OutputLine {
stream: OutputStream::from_db_value(&stream),
Ok(OutputLine::new(
OutputStream::from_db_value(&stream),
text,
})
timestamp,
))
})?
.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('c')) => dashboard.toggle_conflict_protocol_mode(),
(_, 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_ready_worktrees().await,
(_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(),

View File

@@ -1,3 +1,4 @@
use chrono::{Duration, Utc};
use ratatui::{
prelude::*,
widgets::{
@@ -72,6 +73,7 @@ pub struct Dashboard {
selected_merge_readiness: Option<worktree::MergeReadiness>,
output_mode: OutputMode,
output_filter: OutputFilter,
output_time_filter: OutputTimeFilter,
selected_pane: Pane,
selected_session: usize,
show_help: bool,
@@ -123,6 +125,14 @@ enum OutputFilter {
ErrorsOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputTimeFilter {
AllTime,
Last15Minutes,
LastHour,
Last24Hours,
}
#[derive(Debug, Clone, Copy)]
struct PaneAreas {
sessions: Rect,
@@ -201,6 +211,7 @@ impl Dashboard {
selected_merge_readiness: None,
output_mode: OutputMode::SessionOutput,
output_filter: OutputFilter::All,
output_time_filter: OutputTimeFilter::AllTime,
selected_pane: Pane::Sessions,
selected_session: 0,
show_help: false,
@@ -472,7 +483,11 @@ impl Dashboard {
}
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() {
return format!(" Output{filter} /{input}_ ");
}
@@ -490,17 +505,14 @@ impl Dashboard {
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 {
match self.output_filter {
OutputFilter::All => "Waiting for session output...",
OutputFilter::ErrorsOnly => "No stderr output for this session yet.",
match (self.output_filter, self.output_time_filter) {
(OutputFilter::All, OutputTimeFilter::AllTime) => "Waiting for session output...",
(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) {
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.theme_label()
);
@@ -683,6 +695,7 @@ impl Dashboard {
" v Toggle selected worktree diff in output pane",
" c Show conflict-resolution protocol for selected conflicted worktree",
" 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 all ready inactive worktrees and clean them up",
" 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) {
self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs;
match self.cfg.save() {
@@ -2192,7 +2222,9 @@ impl Dashboard {
fn visible_output_lines(&self) -> Vec<&OutputLine> {
self.selected_output_lines()
.iter()
.filter(|line| self.output_filter.matches(line.stream))
.filter(|line| {
self.output_filter.matches(line.stream) && self.output_time_filter.matches(line)
})
.collect()
}
@@ -2864,6 +2896,60 @@ impl OutputFilter {
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 {
@@ -3320,10 +3406,7 @@ mod tests {
);
dashboard.session_output_cache.insert(
"focus-12345678".to_string(),
vec![OutputLine {
stream: OutputStream::Stdout,
text: "last useful output".to_string(),
}],
vec![test_output_line(OutputStream::Stdout, "last useful output")],
);
dashboard.selected_diff_summary = Some("1 file changed, 2 insertions(+)".to_string());
dashboard.selected_diff_preview = vec![
@@ -4160,18 +4243,9 @@ diff --git a/src/next.rs b/src/next.rs
dashboard.session_output_cache.insert(
"focus-12345678".to_string(),
vec![
OutputLine {
stream: OutputStream::Stdout,
text: "alpha".to_string(),
},
OutputLine {
stream: OutputStream::Stdout,
text: "beta".to_string(),
},
OutputLine {
stream: OutputStream::Stdout,
text: "alpha tail".to_string(),
},
test_output_line(OutputStream::Stdout, "alpha"),
test_output_line(OutputStream::Stdout, "beta"),
test_output_line(OutputStream::Stdout, "alpha tail"),
],
);
dashboard.last_output_height = 2;
@@ -4207,18 +4281,9 @@ diff --git a/src/next.rs b/src/next.rs
dashboard.session_output_cache.insert(
"focus-12345678".to_string(),
vec![
OutputLine {
stream: OutputStream::Stdout,
text: "alpha-1".to_string(),
},
OutputLine {
stream: OutputStream::Stdout,
text: "beta".to_string(),
},
OutputLine {
stream: OutputStream::Stdout,
text: "alpha-2".to_string(),
},
test_output_line(OutputStream::Stdout, "alpha-1"),
test_output_line(OutputStream::Stdout, "beta"),
test_output_line(OutputStream::Stdout, "alpha-2"),
],
);
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(
"focus-12345678".to_string(),
vec![
OutputLine {
stream: OutputStream::Stdout,
text: "stdout line".to_string(),
},
OutputLine {
stream: OutputStream::Stderr,
text: "stderr line".to_string(),
},
test_output_line(OutputStream::Stdout, "stdout line"),
test_output_line(OutputStream::Stderr, "stderr line"),
],
);
@@ -4342,18 +4401,9 @@ diff --git a/src/next.rs b/src/next.rs
dashboard.session_output_cache.insert(
"focus-12345678".to_string(),
vec![
OutputLine {
stream: OutputStream::Stdout,
text: "alpha stdout".to_string(),
},
OutputLine {
stream: OutputStream::Stderr,
text: "alpha stderr".to_string(),
},
OutputLine {
stream: OutputStream::Stderr,
text: "beta stderr".to_string(),
},
test_output_line(OutputStream::Stdout, "alpha stdout"),
test_output_line(OutputStream::Stderr, "alpha stderr"),
test_output_line(OutputStream::Stderr, "beta stderr"),
],
);
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");
}
#[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]
async fn stop_selected_uses_session_manager_transition() -> Result<()> {
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);
}
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 {
let selected_session = selected_session.min(sessions.len().saturating_sub(1));
let cfg = Config::default();
@@ -5093,6 +5226,7 @@ diff --git a/src/next.rs b/src/next.rs
selected_merge_readiness: None,
output_mode: OutputMode::SessionOutput,
output_filter: OutputFilter::All,
output_time_filter: OutputTimeFilter::AllTime,
selected_pane: Pane::Sessions,
selected_session,
show_help: false,