diff --git a/src-tauri/src/models/graylog.rs b/src-tauri/src/models/graylog.rs index f29f4a3..5939925 100644 --- a/src-tauri/src/models/graylog.rs +++ b/src-tauri/src/models/graylog.rs @@ -393,6 +393,34 @@ impl GraylogSubject { let rows = stmt.query_map(params![project_id], subject_from_row)?; rows.collect() } + + pub fn list_by_project_and_message( + conn: &Connection, + project_id: &str, + normalized_message: &str, + ) -> Result> { + let mut stmt = conn.prepare( + "SELECT + id, + project_id, + subject_key, + source, + normalized_message, + first_seen_at, + last_seen_at, + last_score, + active_ticket_id, + status, + created_at, + updated_at + FROM graylog_subjects + WHERE project_id = ?1 + AND normalized_message = ?2 + ORDER BY updated_at DESC, id DESC", + )?; + let rows = stmt.query_map(params![project_id, normalized_message], subject_from_row)?; + rows.collect() + } } impl GraylogDetection { @@ -721,4 +749,36 @@ mod tests { assert!(subject_detections[0].triggered); assert_eq!(subject_detections[0].score, 80); } + + #[test] + fn test_list_by_project_and_message_returns_all_matching_subjects() { + let (conn, project, _, _) = setup(); + let message = "timeout while calling payment gateway"; + + GraylogSubject::upsert_subject( + &conn, + &project.id, + "api|timeout", + "api", + message, + "2026-04-17T08:00:00Z", + 42, + ) + .expect("first subject upsert should succeed"); + + GraylogSubject::upsert_subject( + &conn, + &project.id, + "worker|timeout", + "worker", + message, + "2026-04-17T08:05:00Z", + 38, + ) + .expect("second subject upsert should succeed"); + + let subjects = GraylogSubject::list_by_project_and_message(&conn, &project.id, message) + .expect("list by message should succeed"); + assert_eq!(subjects.len(), 2); + } } diff --git a/src-tauri/src/services/graylog_poller.rs b/src-tauri/src/services/graylog_poller.rs index dd1144f..048cd5d 100644 --- a/src-tauri/src/services/graylog_poller.rs +++ b/src-tauri/src/services/graylog_poller.rs @@ -19,6 +19,37 @@ fn should_trigger_subject(score: i32, threshold: i32, has_active_ticket: bool) - score >= threshold && !has_active_ticket } +fn has_active_ticket_for_message( + conn: &Connection, + project_id: &str, + normalized_message: &str, + current_subject_id: &str, +) -> Result { + let related_subjects = + GraylogSubject::list_by_project_and_message(conn, project_id, normalized_message) + .map_err(|e| format!("list related subjects failed: {}", e))?; + + for related in related_subjects { + if related.id == current_subject_id { + continue; + } + + if let Some(active_ticket_id) = related.active_ticket_id.clone() { + match ProcessedTicket::get_by_id(conn, &active_ticket_id) { + Ok(ticket) if is_ticket_active(&ticket.status) => { + return Ok(true); + } + _ => { + GraylogSubject::set_active_ticket(conn, &related.id, None, "idle") + .map_err(|e| format!("clear stale related ticket failed: {}", e))?; + } + } + } + } + + Ok(false) +} + fn synthetic_artifact_id(subject_key: &str) -> i32 { // Stable FNV-1a hash so the synthetic id remains deterministic across runs. const FNV_OFFSET: u64 = 0xcbf29ce484222325; @@ -276,6 +307,15 @@ pub async fn poll_project_once( } } + if !has_active_ticket { + has_active_ticket = has_active_ticket_for_message( + &conn, + project_id, + &aggregate.normalized_message, + &subject.id, + )?; + } + (subject, has_active_ticket) }; @@ -355,7 +395,13 @@ pub async fn poll_project_once( #[cfg(test)] mod tests { - use super::{build_graylog_subject_permalink, should_trigger_subject}; + use super::{ + build_graylog_subject_permalink, has_active_ticket_for_message, should_trigger_subject, + }; + use crate::db; + use crate::models::graylog::GraylogSubject; + use crate::models::project::Project; + use crate::models::ticket::ProcessedTicket; #[test] fn test_should_trigger_subject_respects_active_ticket() { @@ -377,4 +423,52 @@ mod tests { assert!(url.contains("relative=1800")); assert!(url.contains("q=message%3A%22timeout%20user%20%3Cnum%3E%22")); } + + #[test] + fn test_has_active_ticket_for_message_checks_related_subjects() { + let conn = db::init_in_memory().expect("db init should succeed"); + let project = Project::insert(&conn, "Graylog", "/tmp/graylog", None, "main") + .expect("project insert should succeed"); + + let first = GraylogSubject::upsert_subject( + &conn, + &project.id, + "api|timeout user ", + "api", + "timeout user ", + "2026-04-17T10:00:00Z", + 80, + ) + .expect("first subject should upsert"); + let second = GraylogSubject::upsert_subject( + &conn, + &project.id, + "worker|timeout user ", + "worker", + "timeout user ", + "2026-04-17T10:01:00Z", + 70, + ) + .expect("second subject should upsert"); + + let ticket = ProcessedTicket::insert_external( + &conn, + &project.id, + "graylog", + Some(&first.id), + -123, + "[Graylog] timeout user ", + "{}", + ) + .expect("ticket should insert"); + ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing") + .expect("status update should succeed"); + GraylogSubject::set_active_ticket(&conn, &first.id, Some(&ticket.id), "queued") + .expect("active ticket should set"); + + let has_related = + has_active_ticket_for_message(&conn, &project.id, "timeout user ", &second.id) + .expect("related active ticket lookup should succeed"); + assert!(has_related); + } }