From aecc4689e7f025bf22603886acd2084eaef6434c Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Fri, 17 Apr 2026 15:21:12 +0200 Subject: [PATCH] feat(graylog): add model layer and project module default --- src-tauri/src/models/graylog.rs | 699 ++++++++++++++++++++++++++++++++ src-tauri/src/models/mod.rs | 1 + src-tauri/src/models/module.rs | 41 ++ 3 files changed, 741 insertions(+) create mode 100644 src-tauri/src/models/graylog.rs diff --git a/src-tauri/src/models/graylog.rs b/src-tauri/src/models/graylog.rs new file mode 100644 index 0000000..a8cc541 --- /dev/null +++ b/src-tauri/src/models/graylog.rs @@ -0,0 +1,699 @@ +use rusqlite::{params, Connection, OptionalExtension, Result}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraylogCredentials { + pub id: String, + pub project_id: String, + pub base_url: String, + pub api_token_encrypted: String, + pub analyst_agent_id: String, + pub developer_agent_id: String, + pub stream_id: Option, + pub query_filter: String, + pub polling_interval_minutes: i32, + pub lookback_minutes: i32, + pub score_threshold: i32, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraylogCredentialsSafe { + pub id: String, + pub project_id: String, + pub base_url: String, + pub analyst_agent_id: String, + pub developer_agent_id: String, + pub stream_id: Option, + pub query_filter: String, + pub polling_interval_minutes: i32, + pub lookback_minutes: i32, + pub score_threshold: i32, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraylogSubject { + pub id: String, + pub project_id: String, + pub subject_key: String, + pub source: String, + pub normalized_message: String, + pub first_seen_at: String, + pub last_seen_at: String, + pub last_score: i32, + pub active_ticket_id: Option, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraylogDetection { + pub id: String, + pub subject_id: String, + pub window_start: String, + pub window_end: String, + pub critical_count: i32, + pub error_count: i32, + pub warning_count: i32, + pub total_count: i32, + pub last_seen_at: String, + pub score: i32, + pub triggered: bool, + pub triggered_ticket_id: Option, + pub created_at: String, +} + +fn timestamp_now() -> String { + chrono::Utc::now().to_rfc3339() +} + +fn credentials_from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(GraylogCredentials { + id: row.get(0)?, + project_id: row.get(1)?, + base_url: row.get(2)?, + api_token_encrypted: row.get(3)?, + analyst_agent_id: row.get(4)?, + developer_agent_id: row.get(5)?, + stream_id: row.get(6)?, + query_filter: row.get(7)?, + polling_interval_minutes: row.get(8)?, + lookback_minutes: row.get(9)?, + score_threshold: row.get(10)?, + created_at: row.get(11)?, + updated_at: row.get(12)?, + }) +} + +fn subject_from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(GraylogSubject { + id: row.get(0)?, + project_id: row.get(1)?, + subject_key: row.get(2)?, + source: row.get(3)?, + normalized_message: row.get(4)?, + first_seen_at: row.get(5)?, + last_seen_at: row.get(6)?, + last_score: row.get(7)?, + active_ticket_id: row.get(8)?, + status: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, + }) +} + +fn detection_from_row(row: &rusqlite::Row) -> rusqlite::Result { + let triggered_int: i32 = row.get(10)?; + + Ok(GraylogDetection { + id: row.get(0)?, + subject_id: row.get(1)?, + window_start: row.get(2)?, + window_end: row.get(3)?, + critical_count: row.get(4)?, + error_count: row.get(5)?, + warning_count: row.get(6)?, + total_count: row.get(7)?, + last_seen_at: row.get(8)?, + score: row.get(9)?, + triggered: triggered_int != 0, + triggered_ticket_id: row.get(11)?, + created_at: row.get(12)?, + }) +} + +impl GraylogCredentials { + pub fn upsert_for_project( + conn: &Connection, + project_id: &str, + base_url: &str, + api_token_encrypted: &str, + analyst_agent_id: &str, + developer_agent_id: &str, + stream_id: Option<&str>, + query_filter: &str, + polling_interval_minutes: i32, + lookback_minutes: i32, + score_threshold: i32, + ) -> Result { + let now = timestamp_now(); + let id = Uuid::new_v4().to_string(); + + conn.execute( + "INSERT INTO graylog_credentials ( + id, + project_id, + base_url, + api_token_encrypted, + analyst_agent_id, + developer_agent_id, + stream_id, + query_filter, + polling_interval_minutes, + lookback_minutes, + score_threshold, + created_at, + updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ON CONFLICT(project_id) DO UPDATE SET + base_url = excluded.base_url, + api_token_encrypted = excluded.api_token_encrypted, + analyst_agent_id = excluded.analyst_agent_id, + developer_agent_id = excluded.developer_agent_id, + stream_id = excluded.stream_id, + query_filter = excluded.query_filter, + polling_interval_minutes = excluded.polling_interval_minutes, + lookback_minutes = excluded.lookback_minutes, + score_threshold = excluded.score_threshold, + updated_at = excluded.updated_at", + params![ + id, + project_id, + base_url, + api_token_encrypted, + analyst_agent_id, + developer_agent_id, + stream_id, + query_filter, + polling_interval_minutes, + lookback_minutes, + score_threshold, + now, + now, + ], + )?; + + Self::get_by_project(conn, project_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows) + } + + pub fn get_by_project( + conn: &Connection, + project_id: &str, + ) -> Result> { + conn.query_row( + "SELECT + id, + project_id, + base_url, + api_token_encrypted, + analyst_agent_id, + developer_agent_id, + stream_id, + query_filter, + polling_interval_minutes, + lookback_minutes, + score_threshold, + created_at, + updated_at + FROM graylog_credentials + WHERE project_id = ?1 + LIMIT 1", + params![project_id], + credentials_from_row, + ) + .optional() + } + + pub fn delete_for_project(conn: &Connection, project_id: &str) -> Result<()> { + conn.execute( + "DELETE FROM graylog_credentials WHERE project_id = ?1", + params![project_id], + )?; + Ok(()) + } + + pub fn to_safe(&self) -> GraylogCredentialsSafe { + GraylogCredentialsSafe { + id: self.id.clone(), + project_id: self.project_id.clone(), + base_url: self.base_url.clone(), + analyst_agent_id: self.analyst_agent_id.clone(), + developer_agent_id: self.developer_agent_id.clone(), + stream_id: self.stream_id.clone(), + query_filter: self.query_filter.clone(), + polling_interval_minutes: self.polling_interval_minutes, + lookback_minutes: self.lookback_minutes, + score_threshold: self.score_threshold, + created_at: self.created_at.clone(), + updated_at: self.updated_at.clone(), + } + } +} + +impl GraylogSubject { + pub fn upsert_subject( + conn: &Connection, + project_id: &str, + subject_key: &str, + source: &str, + normalized_message: &str, + last_seen_at: &str, + last_score: i32, + ) -> Result { + let now = timestamp_now(); + let id = Uuid::new_v4().to_string(); + + conn.execute( + "INSERT INTO graylog_subjects ( + id, + project_id, + subject_key, + source, + normalized_message, + first_seen_at, + last_seen_at, + last_score, + status, + created_at, + updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 'idle', ?8, ?8) + ON CONFLICT(project_id, subject_key) DO UPDATE SET + source = excluded.source, + normalized_message = excluded.normalized_message, + last_seen_at = excluded.last_seen_at, + last_score = excluded.last_score, + updated_at = excluded.updated_at", + params![ + id, + project_id, + subject_key, + source, + normalized_message, + last_seen_at, + last_score, + now, + ], + )?; + + Self::get_by_project_and_key(conn, project_id, subject_key)? + .ok_or(rusqlite::Error::QueryReturnedNoRows) + } + + pub fn get_by_project_and_key( + conn: &Connection, + project_id: &str, + subject_key: &str, + ) -> Result> { + conn.query_row( + "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 subject_key = ?2 + LIMIT 1", + params![project_id, subject_key], + subject_from_row, + ) + .optional() + } + + pub fn set_active_ticket( + conn: &Connection, + subject_id: &str, + active_ticket_id: Option<&str>, + status: &str, + ) -> Result<()> { + let now = timestamp_now(); + let affected = conn.execute( + "UPDATE graylog_subjects + SET active_ticket_id = ?1, + status = ?2, + updated_at = ?3 + WHERE id = ?4", + params![active_ticket_id, status, now, subject_id], + )?; + + if affected == 0 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + + Ok(()) + } + + pub fn list_by_project(conn: &Connection, project_id: &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 + ORDER BY last_seen_at DESC, created_at DESC, id DESC", + )?; + let rows = stmt.query_map(params![project_id], subject_from_row)?; + rows.collect() + } +} + +impl GraylogDetection { + #[allow(clippy::too_many_arguments)] + pub fn insert( + conn: &Connection, + subject_id: &str, + window_start: &str, + window_end: &str, + critical_count: i32, + error_count: i32, + warning_count: i32, + total_count: i32, + last_seen_at: &str, + score: i32, + triggered: bool, + triggered_ticket_id: Option<&str>, + ) -> Result { + let id = Uuid::new_v4().to_string(); + let created_at = timestamp_now(); + let triggered_int = if triggered { 1i32 } else { 0i32 }; + + conn.execute( + "INSERT INTO graylog_detections ( + id, + subject_id, + window_start, + window_end, + critical_count, + error_count, + warning_count, + total_count, + last_seen_at, + score, + triggered, + triggered_ticket_id, + created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + id, + subject_id, + window_start, + window_end, + critical_count, + error_count, + warning_count, + total_count, + last_seen_at, + score, + triggered_int, + triggered_ticket_id, + created_at, + ], + )?; + + conn.query_row( + "SELECT + id, + subject_id, + window_start, + window_end, + critical_count, + error_count, + warning_count, + total_count, + last_seen_at, + score, + triggered, + triggered_ticket_id, + created_at + FROM graylog_detections + WHERE id = ?1", + params![id], + detection_from_row, + ) + } + + pub fn list_by_project( + conn: &Connection, + project_id: &str, + subject_id: Option<&str>, + ) -> Result> { + let mut stmt = if subject_id.is_some() { + conn.prepare( + "SELECT + gd.id, + gd.subject_id, + gd.window_start, + gd.window_end, + gd.critical_count, + gd.error_count, + gd.warning_count, + gd.total_count, + gd.last_seen_at, + gd.score, + gd.triggered, + gd.triggered_ticket_id, + gd.created_at + FROM graylog_detections gd + JOIN graylog_subjects gs ON gs.id = gd.subject_id + WHERE gs.project_id = ?1 + AND gd.subject_id = ?2 + ORDER BY gd.created_at DESC, gd.id DESC", + )? + } else { + conn.prepare( + "SELECT + gd.id, + gd.subject_id, + gd.window_start, + gd.window_end, + gd.critical_count, + gd.error_count, + gd.warning_count, + gd.total_count, + gd.last_seen_at, + gd.score, + gd.triggered, + gd.triggered_ticket_id, + gd.created_at + FROM graylog_detections gd + JOIN graylog_subjects gs ON gs.id = gd.subject_id + WHERE gs.project_id = ?1 + ORDER BY gd.created_at DESC, gd.id DESC", + )? + }; + + let rows = if let Some(subject_id) = subject_id { + stmt.query_map(params![project_id, subject_id], detection_from_row)? + } else { + stmt.query_map(params![project_id], detection_from_row)? + }; + + rows.collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use crate::models::agent::{Agent, AgentRole, AgentTool}; + use crate::models::project::Project; + use crate::models::ticket::ProcessedTicket; + use rusqlite::Connection; + + fn setup() -> (Connection, Project, Agent, Agent) { + 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 analyst = Agent::insert(&conn, "Analyst", AgentRole::Analyst, AgentTool::Codex, "") + .expect("analyst insert should succeed"); + let developer = Agent::insert( + &conn, + "Developer", + AgentRole::Developer, + AgentTool::ClaudeCode, + "", + ) + .expect("developer insert should succeed"); + + (conn, project, analyst, developer) + } + + #[test] + fn test_upsert_graylog_credentials_for_project() { + let (conn, project, analyst, developer) = setup(); + + let creds = GraylogCredentials::upsert_for_project( + &conn, + &project.id, + "https://graylog.local", + "enc-token", + &analyst.id, + &developer.id, + Some("stream-1"), + "level:(critical OR error)", + 10, + 30, + 70, + ) + .expect("upsert should succeed"); + + assert_eq!(creds.project_id, project.id); + assert_eq!(creds.base_url, "https://graylog.local"); + assert_eq!(creds.api_token_encrypted, "enc-token"); + assert_eq!(creds.stream_id.as_deref(), Some("stream-1")); + + let stored = GraylogCredentials::get_by_project(&conn, &project.id) + .expect("get should succeed") + .expect("credentials should exist"); + assert_eq!(stored.id, creds.id); + assert_eq!(stored.analyst_agent_id, analyst.id); + + let safe = stored.to_safe(); + assert_eq!(safe.project_id, project.id); + assert_eq!(safe.base_url, "https://graylog.local"); + + GraylogCredentials::delete_for_project(&conn, &project.id).expect("delete should succeed"); + let deleted = GraylogCredentials::get_by_project(&conn, &project.id) + .expect("get after delete should succeed"); + assert!(deleted.is_none()); + } + + #[test] + fn test_upsert_subject_and_get_by_project_and_key() { + let (conn, project, _, _) = setup(); + + let first_seen = "2026-04-17T08:00:00Z"; + let second_seen = "2026-04-17T09:00:00Z"; + + let subject = GraylogSubject::upsert_subject( + &conn, + &project.id, + "api:timeout", + "api", + "timeout while calling payment gateway", + first_seen, + 42, + ) + .expect("initial subject upsert should succeed"); + + assert_eq!(subject.project_id, project.id); + assert_eq!(subject.subject_key, "api:timeout"); + assert_eq!(subject.first_seen_at, first_seen); + assert_eq!(subject.last_seen_at, first_seen); + assert_eq!(subject.last_score, 42); + assert_eq!(subject.status, "idle"); + assert!(subject.active_ticket_id.is_none()); + + let ticket = ProcessedTicket::insert_external( + &conn, + &project.id, + "graylog", + Some(&subject.id), + 101, + "[Graylog] api - timeout", + "{\"subject_id\":\"api:timeout\"}", + ) + .expect("ticket insert should succeed"); + + GraylogSubject::upsert_subject( + &conn, + &project.id, + "api:timeout", + "api", + "timeout while calling payment gateway", + second_seen, + 73, + ) + .expect("second subject upsert should succeed"); + GraylogSubject::set_active_ticket(&conn, &subject.id, Some(&ticket.id), "queued") + .expect("set active ticket should succeed"); + + let stored = GraylogSubject::get_by_project_and_key(&conn, &project.id, "api:timeout") + .expect("get should succeed") + .expect("subject should exist"); + assert_eq!(stored.id, subject.id); + assert_eq!(stored.first_seen_at, first_seen); + assert_eq!(stored.last_seen_at, second_seen); + assert_eq!(stored.last_score, 73); + assert_eq!(stored.active_ticket_id.as_deref(), Some(ticket.id.as_str())); + assert_eq!(stored.status, "queued"); + + let subjects = + GraylogSubject::list_by_project(&conn, &project.id).expect("list should succeed"); + assert_eq!(subjects.len(), 1); + assert_eq!(subjects[0].id, subject.id); + } + + #[test] + fn test_insert_detection_and_list_by_project_orders_latest_first() { + let (conn, project, _, _) = setup(); + let subject = GraylogSubject::upsert_subject( + &conn, + &project.id, + "worker:panic", + "worker", + "panic in background worker", + "2026-04-17T08:00:00Z", + 55, + ) + .expect("subject upsert should succeed"); + + let first = GraylogDetection::insert( + &conn, + &subject.id, + "2026-04-17T08:00:00Z", + "2026-04-17T08:10:00Z", + 1, + 3, + 0, + 4, + "2026-04-17T08:09:00Z", + 55, + false, + None, + ) + .expect("first detection insert should succeed"); + let second = GraylogDetection::insert( + &conn, + &subject.id, + "2026-04-17T09:00:00Z", + "2026-04-17T09:10:00Z", + 2, + 4, + 1, + 7, + "2026-04-17T09:09:00Z", + 80, + true, + None, + ) + .expect("second detection insert should succeed"); + + let detections = GraylogDetection::list_by_project(&conn, &project.id, None) + .expect("list should succeed"); + assert_eq!(detections.len(), 2); + assert_eq!(detections[0].id, second.id); + assert_eq!(detections[1].id, first.id); + + let subject_detections = + GraylogDetection::list_by_project(&conn, &project.id, Some(&subject.id)) + .expect("subject list should succeed"); + assert_eq!(subject_detections.len(), 2); + assert!(subject_detections[0].triggered); + assert_eq!(subject_detections[0].score, 80); + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index c2e84a2..490f046 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod agent; pub mod agent_task; pub mod credential; +pub mod graylog; pub mod live_agent; pub mod module; pub mod notification; diff --git a/src-tauri/src/models/module.rs b/src-tauri/src/models/module.rs index 2e4532b..69ab429 100644 --- a/src-tauri/src/models/module.rs +++ b/src-tauri/src/models/module.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; pub const MODULE_TULEAP_AUTO_RESOLVE: &str = "tuleap_polling_auto_resolve"; +pub const MODULE_GRAYLOG_AUTO_RESOLVE: &str = "graylog_polling_auto_resolve"; pub const MODULE_AI_LIVE_CHAT: &str = "ai_live_chat"; pub const MODULE_AGENT_TASK_RUNNER: &str = "agent_task_runner"; @@ -70,6 +71,13 @@ impl ProjectModule { "Polling Tuleap + auto-resolve", "Surveille Tuleap et lance le pipeline analyste/developpeur.", )?; + insert_default( + conn, + project_id, + MODULE_GRAYLOG_AUTO_RESOLVE, + "Polling Graylog + auto-resolve", + "Surveille Graylog, score les sujets, et déclenche le pipeline analyste/developpeur.", + )?; insert_default( conn, project_id, @@ -140,3 +148,36 @@ impl ProjectModule { Ok(enabled_int != 0) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use crate::models::project::Project; + + #[test] + fn test_ensure_defaults_for_project_includes_graylog_module() { + let conn = db::init_in_memory().expect("db init should succeed"); + let project = Project::insert(&conn, "Modules", "/tmp/modules", None, "main") + .expect("project insert should succeed"); + + ProjectModule::ensure_defaults_for_project(&conn, &project.id) + .expect("ensure defaults should succeed"); + + let modules = + ProjectModule::list_by_project(&conn, &project.id).expect("list should succeed"); + assert_eq!(modules.len(), 4); + + let graylog = modules + .iter() + .find(|module| module.module_key == MODULE_GRAYLOG_AUTO_RESOLVE) + .expect("graylog module should be present"); + + assert_eq!(graylog.name, "Polling Graylog + auto-resolve"); + assert_eq!( + graylog.description, + "Surveille Graylog, score les sujets, et déclenche le pipeline analyste/developpeur." + ); + assert!(graylog.enabled); + } +}