# Graylog Auto-Resolve Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a project-scoped Graylog module that detects recurring error/warning patterns, scores them deterministically, and automatically routes high-score subjects through the existing `analyst -> developer` worktree pipeline. **Architecture:** Keep one unified processing queue (`processed_tickets`) and extend it to support multiple sources (`tuleap`, `graylog`). Add dedicated Graylog storage (`graylog_credentials`, `graylog_subjects`, `graylog_detections`), a Graylog polling service, and a queue bridge that inserts Graylog tickets only when score exceeds threshold and dedup allows it. Frontend gets a Graylog project page, module toggle visibility, and live activity hooks. **Tech Stack:** Rust (Tauri, rusqlite, reqwest, tokio), SQLite migrations, React + TypeScript. --- ## Scope Check The spec is one cohesive subsystem (Graylog detection + queue bridge + existing pipeline reuse). It can be implemented as a single plan without splitting into sub-plans. ## File Structure ```text src-tauri/ migrations/ 009_graylog_auto_resolve.sql # create: Graylog tables + processed_tickets multi-source migration src/ db.rs # modify: register migration 009, update schema tests lib.rs # modify: start graylog poller, register graylog commands models/ mod.rs # modify: export graylog model module.rs # modify: add Graylog module default ticket.rs # modify: multi-source ticket fields/query/insert helpers graylog.rs # create: credentials/subjects/detections model methods services/ mod.rs # modify: export graylog services graylog_client.rs # create: Graylog HTTP API client + parsing helpers graylog_scoring.rs # create: normalization/grouping/scoring logic graylog_poller.rs # create: periodic poller + dedup + queue insertion orchestrator.rs # modify: source-aware agent resolution + prompt context commands/ mod.rs # modify: export graylog commands graylog.rs # create: set/get/delete/test/manual poll/list subjects/list detections orchestrator.rs # modify: retry path uses ticket project_id fallback-safe src/ lib/ types.ts # modify: Graylog types + ProcessedTicket new fields api.ts # modify: Graylog API wrappers components/ projects/ ProjectDashboard.tsx # modify: Graylog activity events + Graylog entry card ProjectModules.tsx # modify: show module config link for Graylog card ProjectGraylog.tsx # create: Graylog config + subjects + detections UI tickets/ TicketList.tsx # modify: source badge + filters remain stable TicketDetail.tsx # modify: source/project info rendering for Graylog tickets App.tsx # modify: add /projects/:projectId/graylog route ``` --- ### Task 1: Add migration 009 and bump DB schema version **Files:** - Create: `src-tauri/migrations/009_graylog_auto_resolve.sql` - Modify: `src-tauri/src/db.rs` - [ ] **Step 1: Add failing DB tests for new version and new tables** In `src-tauri/src/db.rs`, update expected table list and user_version assertion: ```rust assert_eq!( tables, vec![ "agents", "graylog_credentials", "graylog_detections", "graylog_subjects", "notifications", "processed_tickets", "project_agent_tasks", "project_live_messages", "project_live_sessions", "project_modules", "projects", "tuleap_credentials", "watched_trackers", "worktrees", ] ); ``` ```rust assert_eq!(version, 9); ``` - [ ] **Step 2: Run DB tests to verify failure before migration wiring** Run: ```bash cd /home/leclere/Projets/IA/orchai/src-tauri cargo test --lib db::tests -- --nocapture ``` Expected: FAIL because migration 009 is not registered yet. - [ ] **Step 3: Create migration 009 with processed_tickets multi-source migration and Graylog tables** Create `src-tauri/migrations/009_graylog_auto_resolve.sql`: ```sql BEGIN; PRAGMA foreign_keys = OFF; DROP INDEX IF EXISTS idx_processed_tickets_tracker_artifact_unique; CREATE TABLE processed_tickets_new ( id TEXT PRIMARY KEY, tracker_id TEXT REFERENCES watched_trackers(id) ON DELETE CASCADE, project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, source TEXT NOT NULL DEFAULT 'tuleap', source_ref TEXT, artifact_id INTEGER NOT NULL, artifact_title TEXT NOT NULL, artifact_data TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'Pending', analyst_report TEXT, developer_report TEXT, worktree_path TEXT, branch_name TEXT, detected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), processed_at TEXT ); INSERT INTO processed_tickets_new ( id, tracker_id, project_id, source, source_ref, artifact_id, artifact_title, artifact_data, status, analyst_report, developer_report, worktree_path, branch_name, detected_at, processed_at ) SELECT pt.id, pt.tracker_id, wt.project_id, 'tuleap', NULL, pt.artifact_id, pt.artifact_title, pt.artifact_data, pt.status, pt.analyst_report, pt.developer_report, pt.worktree_path, pt.branch_name, pt.detected_at, pt.processed_at FROM processed_tickets pt LEFT JOIN watched_trackers wt ON wt.id = pt.tracker_id; DROP TABLE processed_tickets; ALTER TABLE processed_tickets_new RENAME TO processed_tickets; CREATE UNIQUE INDEX idx_processed_tickets_tracker_artifact_unique ON processed_tickets(tracker_id, artifact_id) WHERE tracker_id IS NOT NULL; CREATE INDEX idx_processed_tickets_project_detected ON processed_tickets(project_id, detected_at DESC); CREATE INDEX idx_processed_tickets_source_ref ON processed_tickets(source, source_ref); CREATE TABLE graylog_credentials ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE, base_url TEXT NOT NULL, api_token_encrypted TEXT NOT NULL, analyst_agent_id TEXT NOT NULL REFERENCES agents(id), developer_agent_id TEXT NOT NULL REFERENCES agents(id), stream_id TEXT, query_filter TEXT NOT NULL DEFAULT '', polling_interval_minutes INTEGER NOT NULL DEFAULT 10, lookback_minutes INTEGER NOT NULL DEFAULT 30, score_threshold INTEGER NOT NULL DEFAULT 70, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE TABLE graylog_subjects ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, subject_key TEXT NOT NULL, source TEXT NOT NULL, normalized_message TEXT NOT NULL, first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, last_score INTEGER NOT NULL DEFAULT 0, active_ticket_id TEXT REFERENCES processed_tickets(id), status TEXT NOT NULL DEFAULT 'idle', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), UNIQUE(project_id, subject_key) ); CREATE TABLE graylog_detections ( id TEXT PRIMARY KEY, subject_id TEXT NOT NULL REFERENCES graylog_subjects(id) ON DELETE CASCADE, window_start TEXT NOT NULL, window_end TEXT NOT NULL, critical_count INTEGER NOT NULL DEFAULT 0, error_count INTEGER NOT NULL DEFAULT 0, warning_count INTEGER NOT NULL DEFAULT 0, total_count INTEGER NOT NULL DEFAULT 0, last_seen_at TEXT NOT NULL, score INTEGER NOT NULL, triggered INTEGER NOT NULL DEFAULT 0, triggered_ticket_id TEXT REFERENCES processed_tickets(id), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX idx_graylog_detections_subject_created ON graylog_detections(subject_id, created_at DESC); PRAGMA foreign_keys = ON; COMMIT; ``` - [ ] **Step 4: Register migration 009 in `db.rs`** Add constants and version bump: ```rust const MIGRATION_009: &str = include_str!("../migrations/009_graylog_auto_resolve.sql"); ``` ```rust if version < 9 { conn.execute_batch(MIGRATION_009)?; conn.pragma_update(None, "user_version", 9)?; } ``` - [ ] **Step 5: Run DB tests and full backend tests** Run: ```bash cd /home/leclere/Projets/IA/orchai/src-tauri cargo test --lib db::tests -- --nocapture cargo test --lib -- --nocapture ``` Expected: - DB tests PASS with version `9`. - No migration regression in existing model tests. - [ ] **Step 6: Commit** ```bash cd /home/leclere/Projets/IA/orchai git add src-tauri/migrations/009_graylog_auto_resolve.sql src-tauri/src/db.rs git commit -m "feat(db): add graylog schema and processed_tickets multi-source migration" ``` --- ### Task 2: Extend `ProcessedTicket` for multi-source queue **Files:** - Modify: `src-tauri/src/models/ticket.rs` - [ ] **Step 1: Add failing tests for new ticket shape and project-scoped queries** Add tests in `ticket.rs`: ```rust #[test] fn test_insert_if_new_sets_project_and_source_for_tuleap() { let (conn, tracker_id) = setup(); let project_id = project_id_for_tracker(&conn, &tracker_id); let ticket = ProcessedTicket::insert_if_new( &conn, &project_id, &tracker_id, 777, "Tracker issue", "{}", ) .unwrap() .unwrap(); assert_eq!(ticket.project_id, project_id); assert_eq!(ticket.source, "tuleap"); assert_eq!(ticket.source_ref, None); assert_eq!(ticket.tracker_id.as_deref(), Some(tracker_id.as_str())); } #[test] fn test_insert_external_graylog_ticket() { let (conn, tracker_id) = setup(); let project_id = project_id_for_tracker(&conn, &tracker_id); let ticket = ProcessedTicket::insert_external( &conn, &project_id, "graylog", Some("subject-1"), -42, "[Graylog] api - timeout", "{\"score\":83}", ) .unwrap(); assert_eq!(ticket.project_id, project_id); assert_eq!(ticket.source, "graylog"); assert_eq!(ticket.source_ref.as_deref(), Some("subject-1")); assert!(ticket.tracker_id.is_none()); } ``` - [ ] **Step 2: Run ticket model tests and confirm compilation fails** Run: ```bash cd /home/leclere/Projets/IA/orchai/src-tauri cargo test --lib models::ticket::tests -- --nocapture ``` Expected: FAIL because `project_id/source/source_ref` fields and new methods do not exist yet. - [ ] **Step 3: Implement multi-source fields and insert/query helpers** Update `ProcessedTicket` and SQL mapping: ```rust pub struct ProcessedTicket { pub id: String, pub tracker_id: Option, pub project_id: String, pub source: String, pub source_ref: Option, pub artifact_id: i32, pub artifact_title: String, pub artifact_data: String, pub status: String, pub analyst_report: Option, pub developer_report: Option, pub worktree_path: Option, pub branch_name: Option, pub detected_at: String, pub processed_at: Option, } ``` ```rust const SELECT_ALL_COLS: &str = "SELECT id, tracker_id, project_id, source, source_ref, \ artifact_id, artifact_title, artifact_data, status, analyst_report, developer_report, \ worktree_path, branch_name, detected_at, processed_at FROM processed_tickets"; ``` ```rust pub fn insert_if_new( conn: &Connection, project_id: &str, tracker_id: &str, artifact_id: i32, artifact_title: &str, artifact_data: &str, ) -> Result> { let id = Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); let inserted_rows = conn.execute( "INSERT OR IGNORE INTO processed_tickets \ (id, tracker_id, project_id, source, source_ref, artifact_id, artifact_title, artifact_data, status, detected_at) \ VALUES (?1, ?2, ?3, 'tuleap', NULL, ?4, ?5, ?6, 'Pending', ?7)", params![id, tracker_id, project_id, artifact_id, artifact_title, artifact_data, now], )?; if inserted_rows == 0 { return Ok(None); } Ok(Some(ProcessedTicket { id, tracker_id: Some(tracker_id.to_string()), project_id: project_id.to_string(), source: "tuleap".to_string(), source_ref: None, artifact_id, artifact_title: artifact_title.to_string(), artifact_data: artifact_data.to_string(), status: "Pending".to_string(), analyst_report: None, developer_report: None, worktree_path: None, branch_name: None, detected_at: now, processed_at: None, })) } ``` ```rust pub fn insert_external( conn: &Connection, project_id: &str, source: &str, source_ref: Option<&str>, artifact_id: i32, artifact_title: &str, artifact_data: &str, ) -> Result { let id = Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); conn.execute( "INSERT INTO processed_tickets \ (id, tracker_id, project_id, source, source_ref, artifact_id, artifact_title, artifact_data, status, detected_at) \ VALUES (?1, NULL, ?2, ?3, ?4, ?5, ?6, ?7, 'Pending', ?8)", params![id, project_id, source, source_ref, artifact_id, artifact_title, artifact_data, now], )?; Ok(ProcessedTicket { id, tracker_id: None, project_id: project_id.to_string(), source: source.to_string(), source_ref: source_ref.map(str::to_string), artifact_id, artifact_title: artifact_title.to_string(), artifact_data: artifact_data.to_string(), status: "Pending".to_string(), analyst_report: None, developer_report: None, worktree_path: None, branch_name: None, detected_at: now, processed_at: None, }) } ``` ```rust pub fn list_by_project(conn: &Connection, project_id: &str) -> Result> { let sql = format!( "{} WHERE project_id = ?1 ORDER BY detected_at DESC", SELECT_ALL_COLS ); let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map(params![project_id], from_row)?; rows.collect() } ``` ```rust pub fn get_project_throughput_stats( conn: &Connection, project_id: &str, ) -> Result { let window_start = (chrono::Utc::now() - chrono::Duration::hours(24)).to_rfc3339(); conn.query_row( "SELECT COALESCE(SUM(CASE WHEN status NOT IN ('Done','Error','Cancelled') THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN status = 'Done' AND processed_at IS NOT NULL AND julianday(processed_at) >= julianday(?2) THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN status = 'Error' AND processed_at IS NOT NULL AND julianday(processed_at) >= julianday(?2) THEN 1 ELSE 0 END), 0), AVG(CASE WHEN status IN ('Done','Error') AND processed_at IS NOT NULL AND julianday(processed_at) >= julianday(?2) THEN (julianday(processed_at) - julianday(detected_at)) * 86400.0 ELSE NULL END) FROM processed_tickets WHERE project_id = ?1", params![project_id, window_start], |row| { Ok(ProjectThroughputStats { backlog_count: row.get(0)?, done_last_24h: row.get(1)?, error_last_24h: row.get(2)?, avg_lead_time_seconds: row.get(3)?, }) }, ) } ``` - [ ] **Step 4: Update all call sites using `insert_if_new`** Adjust: - `src-tauri/src/services/poller.rs` - `src-tauri/src/commands/poller.rs` Snippet to apply at both call sites: ```rust if let Some(ticket) = ProcessedTicket::insert_if_new( &db, &tracker.project_id, &tracker.id, artifact_id, &artifact_title, &artifact_data, )? { // existing flow } ``` - [ ] **Step 5: Run ticket tests and backend regression tests** Run: ```bash cd /home/leclere/Projets/IA/orchai/src-tauri cargo test --lib models::ticket::tests -- --nocapture cargo test --lib commands::poller -- --nocapture ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash cd /home/leclere/Projets/IA/orchai git add src-tauri/src/models/ticket.rs src-tauri/src/services/poller.rs src-tauri/src/commands/poller.rs git commit -m "feat(queue): support multi-source processed tickets" ``` --- ### Task 3: Add Graylog model layer and module defaults **Files:** - Create: `src-tauri/src/models/graylog.rs` - Modify: `src-tauri/src/models/mod.rs` - Modify: `src-tauri/src/models/module.rs` - [ ] **Step 1: Add failing tests for Graylog credentials and subjects** In new `graylog.rs` test module, add: ```rust #[test] fn test_upsert_graylog_credentials_for_project() { let conn = db::init_in_memory().unwrap(); let project = Project::insert(&conn, "Gray", "/tmp/gray", None, "main").unwrap(); let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); let developer = Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap(); 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, ).unwrap(); assert_eq!(creds.project_id, project.id); assert_eq!(creds.base_url, "https://graylog.local"); } ``` - [ ] **Step 2: Implement `graylog.rs` with credentials, subjects, detections** Create `src-tauri/src/models/graylog.rs` with these core structs and methods: ```rust #[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, } ``` ```rust 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 = chrono::Utc::now().to_rfc3339(); 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![ Uuid::new_v4().to_string(), 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 ], )?; GraylogCredentials::get_by_project(conn, project_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows) } ``` ```rust 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 = chrono::Utc::now().to_rfc3339(); 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![Uuid::new_v4().to_string(), project_id, subject_key, source, normalized_message, last_seen_at, last_score, now], )?; GraylogSubject::get_by_project_and_key(conn, project_id, subject_key)? .ok_or(rusqlite::Error::QueryReturnedNoRows) } ``` - [ ] **Step 3: Wire model exports and module default** In `src-tauri/src/models/mod.rs`: ```rust pub mod graylog; ``` In `src-tauri/src/models/module.rs`: ```rust pub const MODULE_GRAYLOG_AUTO_RESOLVE: &str = "graylog_polling_auto_resolve"; ``` And add default insertion: ```rust 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.", )?; ``` - [ ] **Step 4: Run focused model tests** Run: ```bash cd /home/leclere/Projets/IA/orchai/src-tauri cargo test --lib models::graylog -- --nocapture cargo test --lib models::module -- --nocapture ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash cd /home/leclere/Projets/IA/orchai git add src-tauri/src/models/graylog.rs src-tauri/src/models/mod.rs src-tauri/src/models/module.rs git commit -m "feat(graylog): add model layer and project module default" ``` --- ### Task 4: Implement Graylog scoring engine (normalize/group/score) **Files:** - Create: `src-tauri/src/services/graylog_scoring.rs` - Modify: `src-tauri/src/services/mod.rs` - Modify: `src-tauri/Cargo.toml` - [ ] **Step 1: Add failing tests for normalization and score** In `graylog_scoring.rs` tests: ```rust #[test] fn test_normalize_message_replaces_dynamic_values() { let message = "User 42 failed from 10.0.0.1 at 2026-04-17T10:00:00Z request=9f8b-12ab"; let normalized = normalize_message(message); assert!(normalized.contains("")); assert!(normalized.contains("")); assert!(normalized.contains("")); } #[test] fn test_compute_score_critical_recent_is_high() { let score = compute_score(SeverityCounts { critical: 1, error: 0, warning: 0 }, 2, 30); assert!(score >= 70); } ``` - [ ] **Step 2: Implement scoring primitives and grouping** Create `graylog_scoring.rs`: ```rust use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GraylogEvent { pub timestamp: String, pub source: String, pub service: Option, pub level: String, pub message: String, pub raw: serde_json::Value, } #[derive(Debug, Clone, Copy, Default)] pub struct SeverityCounts { pub critical: i32, pub error: i32, pub warning: i32, } #[derive(Debug, Clone)] pub struct SubjectAggregate { pub subject_key: String, pub source: String, pub normalized_message: String, pub counts: SeverityCounts, pub total_count: i32, pub last_seen_age_minutes: i64, pub last_seen_at: String, pub sample_events: Vec, pub score: i32, } pub fn normalize_message(input: &str) -> String { let mut value = input.to_lowercase(); let uuid_re = Regex::new(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b").unwrap(); let ip_re = Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").unwrap(); let ts_re = Regex::new(r"\b\d{4}-\d{2}-\d{2}t\d{2}:\d{2}:\d{2}(?:\.\d+)?z\b").unwrap(); let hash_re = Regex::new(r"\b[0-9a-f]{12,}\b").unwrap(); let num_re = Regex::new(r"\b\d+\b").unwrap(); let ws_re = Regex::new(r"\s+").unwrap(); value = uuid_re.replace_all(&value, "").to_string(); value = ip_re.replace_all(&value, "").to_string(); value = ts_re.replace_all(&value, "").to_string(); value = hash_re.replace_all(&value, "").to_string(); value = num_re.replace_all(&value, "").to_string(); ws_re.replace_all(&value, " ").trim().to_string() } pub fn compute_score(counts: SeverityCounts, total_count: i32, last_seen_age_minutes: i64) -> i32 { let severity_score = if counts.critical > 0 { 50 } else if counts.error > 0 { 35 } else if counts.warning > 0 { 20 } else { 0 }; let frequency_score = match total_count { 0 => 0, 1 => 5, 2..=3 => 12, 4..=7 => 22, 8..=15 => 30, _ => 35, }; let recency_score = if last_seen_age_minutes <= 2 { 15 } else if last_seen_age_minutes <= 10 { 12 } else if last_seen_age_minutes <= 30 { 8 } else if last_seen_age_minutes <= 120 { 4 } else { 0 }; severity_score + frequency_score + recency_score } pub fn group_subjects(events: &[GraylogEvent], now: chrono::DateTime) -> Vec { let mut map: HashMap = HashMap::new(); for event in events { let source = event.service.clone().unwrap_or_else(|| event.source.clone()); let normalized_message = normalize_message(&event.message); let subject_key = format!("{source}|{normalized_message}"); let event_time = chrono::DateTime::parse_from_rfc3339(&event.timestamp) .map(|dt| dt.with_timezone(&chrono::Utc)) .unwrap_or(now); let age_minutes = now.signed_duration_since(event_time).num_minutes().max(0); let entry = map.entry(subject_key.clone()).or_insert_with(|| SubjectAggregate { subject_key: subject_key.clone(), source: source.clone(), normalized_message: normalized_message.clone(), counts: SeverityCounts::default(), total_count: 0, last_seen_age_minutes: age_minutes, last_seen_at: event.timestamp.clone(), sample_events: Vec::new(), score: 0, }); entry.total_count += 1; let level = event.level.to_lowercase(); if level.contains("critical") { entry.counts.critical += 1; } else if level.contains("error") { entry.counts.error += 1; } else if level.contains("warn") { entry.counts.warning += 1; } if age_minutes < entry.last_seen_age_minutes { entry.last_seen_age_minutes = age_minutes; entry.last_seen_at = event.timestamp.clone(); } if entry.sample_events.len() < 5 { entry.sample_events.push(event.raw.clone()); } } let mut out: Vec = map .into_values() .map(|mut aggregate| { aggregate.score = compute_score( aggregate.counts, aggregate.total_count, aggregate.last_seen_age_minutes, ); aggregate }) .collect(); out.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| b.total_count.cmp(&a.total_count))); out } ``` - [ ] **Step 3: Add Rust dependencies required by scoring/client utilities** In `src-tauri/Cargo.toml` under `[dependencies]`: ```toml regex = "1" urlencoding = "2" ``` - [ ] **Step 4: Export service module** In `src-tauri/src/services/mod.rs`: ```rust pub mod graylog_scoring; ``` - [ ] **Step 5: Run scoring tests** Run: ```bash cd /home/leclere/Projets/IA/orchai/src-tauri cargo test --lib services::graylog_scoring -- --nocapture ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash cd /home/leclere/Projets/IA/orchai git add src-tauri/src/services/graylog_scoring.rs src-tauri/src/services/mod.rs src-tauri/Cargo.toml git commit -m "feat(graylog): add deterministic scoring and subject grouping" ``` --- ### Task 5: Implement Graylog API client and backend commands **Files:** - Create: `src-tauri/src/services/graylog_client.rs` - Create: `src-tauri/src/commands/graylog.rs` - Modify: `src-tauri/src/commands/mod.rs` - Modify: `src-tauri/src/lib.rs` - [ ] **Step 1: Add failing tests for Graylog payload parsing** In `graylog_client.rs` tests: ```rust #[test] fn test_parse_search_response_extracts_events() { let payload = serde_json::json!({ "messages": [ { "message": { "timestamp": "2026-04-17T10:00:00.000Z", "source": "api-1", "level": "error", "message": "timeout id=42", "service": "api" } } ] }); let events = parse_search_response(&payload); assert_eq!(events.len(), 1); assert_eq!(events[0].source, "api-1"); assert_eq!(events[0].service.as_deref(), Some("api")); } ``` - [ ] **Step 2: Implement Graylog client** Create `src-tauri/src/services/graylog_client.rs`: ```rust use crate::services::graylog_scoring::GraylogEvent; use serde_json::Value; use std::time::Instant; use tokio::time::{sleep, Duration}; pub struct GraylogClient { http: reqwest::Client, base_url: String, token: String, } impl GraylogClient { pub fn new(http: &reqwest::Client, base_url: &str, token: &str) -> Self { Self { http: http.clone(), base_url: base_url.trim_end_matches('/').to_string(), token: token.to_string(), } } async fn send_get(&self, url: &str) -> Result { const MAX_ATTEMPTS: u32 = 3; const BASE_DELAY_MS: u64 = 500; for attempt in 1..=MAX_ATTEMPTS { let started_at = Instant::now(); let response = self .http .get(url) .header("Authorization", format!("Bearer {}", self.token)) .header("X-Requested-By", "orchai") .send() .await; match response { Ok(resp) => { let status = resp.status(); if (status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) && attempt < MAX_ATTEMPTS { let delay_ms = BASE_DELAY_MS * 2u64.pow(attempt - 1); eprintln!("[graylog] retry GET {} after {}ms (status={})", url, delay_ms, status); sleep(Duration::from_millis(delay_ms)).await; continue; } eprintln!("[graylog] GET {} status={} {}ms", url, status, started_at.elapsed().as_millis()); return Ok(resp); } Err(err) => { if attempt < MAX_ATTEMPTS { let delay_ms = BASE_DELAY_MS * 2u64.pow(attempt - 1); eprintln!("[graylog] retry GET {} after {}ms ({})", url, delay_ms, err); sleep(Duration::from_millis(delay_ms)).await; continue; } return Err(format!("graylog request failed: {}", err)); } } } Err("graylog request failed after retries".to_string()) } pub async fn test_connection(&self) -> Result<(), String> { let url = format!("{}/api/system", self.base_url); let resp = self.send_get(&url).await?; if resp.status().is_success() { Ok(()) } else { Err(format!("graylog connection test failed: HTTP {}", resp.status())) } } pub async fn search_relative( &self, query: &str, stream_id: Option<&str>, range_seconds: i32, ) -> Result, String> { let mut url = format!( "{}/api/search/universal/relative?query={}&range={}&limit=500", self.base_url, urlencoding::encode(query), range_seconds ); if let Some(stream_id) = stream_id { url.push_str(&format!("&streams={}", urlencoding::encode(stream_id))); } let resp = self.send_get(&url).await?; if !resp.status().is_success() { return Err(format!("graylog search failed: HTTP {}", resp.status())); } let body: Value = resp.json().await.map_err(|e| format!("invalid graylog JSON: {}", e))?; Ok(parse_search_response(&body)) } } pub fn parse_search_response(body: &Value) -> Vec { let rows = body.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default(); rows.into_iter() .filter_map(|row| { let message = row.get("message")?; let timestamp = message.get("timestamp").and_then(|v| v.as_str()).unwrap_or("").to_string(); let source = message.get("source").and_then(|v| v.as_str()).unwrap_or("").to_string(); let level = message.get("level") .and_then(|v| v.as_str()) .map(str::to_string) .unwrap_or_else(|| message.get("level").map(|v| v.to_string()).unwrap_or_default()); let msg = message.get("message").and_then(|v| v.as_str()).unwrap_or("").to_string(); if timestamp.is_empty() || source.is_empty() || msg.is_empty() { return None; } let service = message.get("service").and_then(|v| v.as_str()).map(str::to_string); Some(GraylogEvent { timestamp, source, service, level, message: msg, raw: message.clone(), }) }) .collect() } ``` - [ ] **Step 3: Implement Graylog commands skeleton with credential CRUD and test/manual operations** Create `src-tauri/src/commands/graylog.rs` with: ```rust #[tauri::command] pub fn set_graylog_credentials( state: State<'_, AppState>, project_id: String, base_url: String, api_token: String, analyst_agent_id: String, developer_agent_id: String, stream_id: Option, query_filter: String, polling_interval_minutes: i32, lookback_minutes: i32, score_threshold: i32, ) -> Result { let token_encrypted = crypto::encrypt(&state.encryption_key, api_token.trim()) .map_err(AppError::from)?; let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; let creds = GraylogCredentials::upsert_for_project( &db, &project_id, base_url.trim(), &token_encrypted, analyst_agent_id.trim(), developer_agent_id.trim(), stream_id.as_deref(), query_filter.trim(), polling_interval_minutes, lookback_minutes, score_threshold, )?; Ok(creds.to_safe()) } #[tauri::command] pub async fn test_graylog_connection( state: State<'_, AppState>, project_id: String, ) -> Result { let (base_url, token) = { let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; let creds = GraylogCredentials::get_by_project(&db, &project_id)? .ok_or_else(|| AppError::from("No Graylog credentials configured".to_string()))?; let token = crypto::decrypt(&state.encryption_key, &creds.api_token_encrypted) .map_err(AppError::from)?; (creds.base_url, token) }; let client = GraylogClient::new(&state.http_client, &base_url, &token); client.test_connection().await.map_err(AppError::from)?; Ok("Connection successful".to_string()) } #[tauri::command] pub async fn manual_graylog_poll( state: State<'_, AppState>, app_handle: tauri::AppHandle, project_id: String, ) -> Result { let count = crate::services::graylog_poller::poll_project_once( &state.db, &state.encryption_key, &state.http_client, &app_handle, &project_id, ) .await .map_err(AppError::from)?; Ok(count) } ``` - [ ] **Step 4: Register commands and service exports** In `src-tauri/src/commands/mod.rs`: ```rust pub mod graylog; ``` In `src-tauri/src/services/mod.rs`: ```rust pub mod graylog_client; ``` In `src-tauri/src/lib.rs` invoke handler: ```rust commands::graylog::set_graylog_credentials, commands::graylog::get_graylog_credentials, commands::graylog::delete_graylog_credentials, commands::graylog::test_graylog_connection, commands::graylog::manual_graylog_poll, commands::graylog::list_graylog_subjects, commands::graylog::list_graylog_detections, ``` - [ ] **Step 5: Run command and client tests** Run: ```bash cd /home/leclere/Projets/IA/orchai/src-tauri cargo test --lib services::graylog_client -- --nocapture cargo test --lib commands::graylog -- --nocapture ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash cd /home/leclere/Projets/IA/orchai git add src-tauri/src/services/graylog_client.rs src-tauri/src/commands/graylog.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs src-tauri/src/services/mod.rs git commit -m "feat(graylog): add client and tauri commands" ``` --- ### Task 6: Implement Graylog poller and orchestrator source-aware behavior **Files:** - Create: `src-tauri/src/services/graylog_poller.rs` - Modify: `src-tauri/src/services/orchestrator.rs` - Modify: `src-tauri/src/services/mod.rs` - Modify: `src-tauri/src/commands/orchestrator.rs` - Modify: `src-tauri/src/lib.rs` - [ ] **Step 1: Add failing tests for dedup strictness** In `graylog_poller.rs` tests: ```rust #[test] fn test_should_trigger_subject_respects_active_ticket() { assert!(should_trigger_subject(82, 70, false)); assert!(!should_trigger_subject(82, 70, true)); assert!(!should_trigger_subject(60, 70, false)); } ``` With helper: ```rust fn should_trigger_subject(score: i32, threshold: i32, has_active_ticket: bool) -> bool { score >= threshold && !has_active_ticket } ``` - [ ] **Step 2: Implement `graylog_poller.rs` service** Create `src-tauri/src/services/graylog_poller.rs`: ```rust use crate::models::graylog::{GraylogCredentials, GraylogDetection, GraylogSubject}; use crate::models::module::{ProjectModule, MODULE_GRAYLOG_AUTO_RESOLVE}; use crate::models::ticket::ProcessedTicket; use crate::services::graylog_client::GraylogClient; use crate::services::graylog_scoring::{group_subjects, SubjectAggregate}; use crate::services::crypto; use rusqlite::Connection; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter}; use tokio::time::{interval, Duration}; fn should_trigger_subject(score: i32, threshold: i32, has_active_ticket: bool) -> bool { score >= threshold && !has_active_ticket } fn synthetic_artifact_id(subject_key: &str) -> i32 { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); subject_key.hash(&mut hasher); let raw = (hasher.finish() & 0x3fff_ffff) as i32; -raw.max(1) } fn subject_payload(aggregate: &SubjectAggregate) -> String { serde_json::json!({ "source": aggregate.source, "normalized_message": aggregate.normalized_message, "counts": { "critical": aggregate.counts.critical, "error": aggregate.counts.error, "warning": aggregate.counts.warning, "total": aggregate.total_count }, "last_seen_at": aggregate.last_seen_at, "score": aggregate.score, "samples": aggregate.sample_events }).to_string() } ``` And core poll entrypoint: ```rust pub async fn poll_project_once( db: &Arc>, encryption_key: &[u8; 32], http_client: &reqwest::Client, app_handle: &AppHandle, project_id: &str, ) -> Result { let creds = { let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; let enabled = ProjectModule::is_enabled(&conn, project_id, MODULE_GRAYLOG_AUTO_RESOLVE) .map_err(|e| format!("module lookup failed: {}", e))?; if !enabled { return Ok(0); } GraylogCredentials::get_by_project(&conn, project_id) .map_err(|e| format!("credentials lookup failed: {}", e))? .ok_or_else(|| "No Graylog credentials configured".to_string())? }; let token = crypto::decrypt(encryption_key, &creds.api_token_encrypted) .map_err(|e| format!("token decrypt failed: {}", e))?; let client = GraylogClient::new(http_client, &creds.base_url, &token); let query = if creds.query_filter.trim().is_empty() { "level:(critical OR error OR warning)".to_string() } else { creds.query_filter.clone() }; let events = client .search_relative(&query, creds.stream_id.as_deref(), creds.lookback_minutes * 60) .await?; let now = chrono::Utc::now(); let aggregates = group_subjects(&events, now); let mut triggered_count = 0i32; let _ = app_handle.emit("graylog-polling-started", serde_json::json!({ "project_id": project_id })); for aggregate in aggregates { let (subject, active_in_progress) = { let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; let subject = GraylogSubject::upsert_subject( &conn, project_id, &aggregate.subject_key, &aggregate.source, &aggregate.normalized_message, &aggregate.last_seen_at, aggregate.score, ).map_err(|e| format!("upsert subject failed: {}", e))?; let active = match &subject.active_ticket_id { Some(ticket_id) => ProcessedTicket::get_by_id(&conn, ticket_id) .map(|t| matches!(t.status.as_str(), "Pending" | "Analyzing" | "Developing")) .unwrap_or(false), None => false, }; (subject, active) }; let should_trigger = should_trigger_subject( aggregate.score, creds.score_threshold, active_in_progress, ); let triggered_ticket_id = if should_trigger { let mut conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; let ticket = ProcessedTicket::insert_external( &conn, project_id, "graylog", Some(&subject.id), synthetic_artifact_id(&aggregate.subject_key), &format!("[Graylog] {} - {}", aggregate.source, aggregate.normalized_message), &subject_payload(&aggregate), ).map_err(|e| format!("insert graylog ticket failed: {}", e))?; GraylogSubject::set_active_ticket(&conn, &subject.id, Some(&ticket.id), "queued") .map_err(|e| format!("set active ticket failed: {}", e))?; triggered_count += 1; let _ = app_handle.emit("graylog-subject-triggered", serde_json::json!({ "project_id": project_id, "subject_id": subject.id, "ticket_id": ticket.id, "score": aggregate.score })); Some(ticket.id) } else { None }; { let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; GraylogDetection::insert( &conn, &subject.id, &(now - chrono::Duration::minutes(creds.lookback_minutes as i64)).to_rfc3339(), &now.to_rfc3339(), aggregate.counts.critical, aggregate.counts.error, aggregate.counts.warning, aggregate.total_count, &aggregate.last_seen_at, aggregate.score, should_trigger, triggered_ticket_id.as_deref(), ).map_err(|e| format!("insert detection failed: {}", e))?; } } let _ = app_handle.emit("graylog-polling-finished", serde_json::json!({ "project_id": project_id, "triggered_count": triggered_count })); Ok(triggered_count) } ``` - [ ] **Step 3: Start background poller and export module** In `src-tauri/src/services/mod.rs`: ```rust pub mod graylog_poller; ``` In `src-tauri/src/lib.rs` setup: ```rust services::graylog_poller::start( db_arc.clone(), encryption_key, http_client.clone(), app.handle().clone(), ); ``` - [ ] **Step 4: Make orchestrator source-aware** In `src-tauri/src/services/orchestrator.rs`, use ticket source to resolve project/agents: ```rust let (project, analyst_agent, developer_agent) = { let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; let project = crate::models::project::Project::get_by_id(&conn, &ticket.project_id) .map_err(|e| format!("project lookup failed: {}", e))?; if ticket.source == "graylog" { let cfg = crate::models::graylog::GraylogCredentials::get_by_project(&conn, &project.id) .map_err(|e| format!("graylog credentials lookup failed: {}", e))? .ok_or_else(|| "Graylog credentials are missing".to_string())?; let analyst_agent = Agent::get_by_id(&conn, &cfg.analyst_agent_id) .map_err(|_| "Configured graylog analyst not found".to_string())?; let developer_agent = Agent::get_by_id(&conn, &cfg.developer_agent_id) .map_err(|_| "Configured graylog developer not found".to_string())?; (project, analyst_agent, developer_agent) } else { let tracker_id = ticket.tracker_id.as_deref().ok_or_else(|| "Missing tracker_id for tuleap ticket".to_string())?; let tracker = WatchedTracker::get_by_id(&conn, tracker_id) .map_err(|e| format!("get tracker failed: {}", e))?; let analyst_id = tracker.analyst_agent_id.as_deref().ok_or_else(|| "Tracker has no analyst".to_string())?; let developer_id = tracker.developer_agent_id.as_deref().ok_or_else(|| "Tracker has no developer".to_string())?; let analyst_agent = Agent::get_by_id(&conn, analyst_id).map_err(|_| "Configured analyst not found".to_string())?; let developer_agent = Agent::get_by_id(&conn, developer_id).map_err(|_| "Configured developer not found".to_string())?; (project, analyst_agent, developer_agent) } }; ``` Also update `commands/orchestrator.rs` retry cleanup project resolution: ```rust let project = crate::models::project::Project::get_by_id(&conn, &ticket.project_id)?; ``` - [ ] **Step 5: Run service and orchestrator tests** Run: ```bash cd /home/leclere/Projets/IA/orchai/src-tauri cargo test --lib services::graylog_poller -- --nocapture cargo test --lib services::orchestrator -- --nocapture cargo test --lib commands::orchestrator -- --nocapture ``` Expected: PASS with dedup behavior covered. - [ ] **Step 6: Commit** ```bash cd /home/leclere/Projets/IA/orchai git add src-tauri/src/services/graylog_poller.rs src-tauri/src/services/orchestrator.rs src-tauri/src/commands/orchestrator.rs src-tauri/src/services/mod.rs src-tauri/src/lib.rs git commit -m "feat(graylog): add poller bridge and source-aware orchestrator flow" ``` --- ### Task 7: Frontend API/types wiring for Graylog **Files:** - Modify: `src/lib/types.ts` - Modify: `src/lib/api.ts` - [ ] **Step 1: Add TS types** In `src/lib/types.ts`: ```ts export interface GraylogCredentialsSafe { id: string; project_id: string; base_url: string; analyst_agent_id: string; developer_agent_id: string; stream_id: string | null; query_filter: string; polling_interval_minutes: number; lookback_minutes: number; score_threshold: number; } export interface GraylogSubject { id: string; project_id: string; subject_key: string; source: string; normalized_message: string; first_seen_at: string; last_seen_at: string; last_score: number; active_ticket_id: string | null; status: string; } export interface GraylogDetection { id: string; subject_id: string; window_start: string; window_end: string; critical_count: number; error_count: number; warning_count: number; total_count: number; last_seen_at: string; score: number; triggered: boolean; triggered_ticket_id: string | null; created_at: string; } ``` Also extend `ProcessedTicket`: ```ts tracker_id: string | null; project_id: string; source: string; source_ref: string | null; ``` - [ ] **Step 2: Add Graylog API wrappers** In `src/lib/api.ts`: ```ts export async function getGraylogCredentials(projectId: string): Promise { return invoke("get_graylog_credentials", { projectId }); } export async function setGraylogCredentials( projectId: string, baseUrl: string, apiToken: string, analystAgentId: string, developerAgentId: string, streamId: string | null, queryFilter: string, pollingIntervalMinutes: number, lookbackMinutes: number, scoreThreshold: number ): Promise { return invoke("set_graylog_credentials", { projectId, baseUrl, apiToken, analystAgentId, developerAgentId, streamId, queryFilter, pollingIntervalMinutes, lookbackMinutes, scoreThreshold, }); } export async function listGraylogSubjects(projectId: string): Promise { return invoke("list_graylog_subjects", { projectId }); } export async function listGraylogDetections(projectId: string, subjectId?: string): Promise { return invoke("list_graylog_detections", { projectId, subjectId }); } export async function testGraylogConnection(projectId: string): Promise { return invoke("test_graylog_connection", { projectId }); } export async function manualGraylogPoll(projectId: string): Promise { return invoke("manual_graylog_poll", { projectId }); } ``` - [ ] **Step 3: Run frontend typecheck** Run: ```bash cd /home/leclere/Projets/IA/orchai npm run qa:frontend ``` Expected: PASS. - [ ] **Step 4: Commit** ```bash cd /home/leclere/Projets/IA/orchai git add src/lib/types.ts src/lib/api.ts git commit -m "feat(frontend): add graylog types and API bindings" ``` --- ### Task 8: Build Graylog project UI and live activity hooks **Files:** - Create: `src/components/projects/ProjectGraylog.tsx` - Modify: `src/components/projects/ProjectDashboard.tsx` - Modify: `src/components/projects/ProjectModules.tsx` - Modify: `src/components/tickets/TicketList.tsx` - Modify: `src/components/tickets/TicketDetail.tsx` - Modify: `src/App.tsx` - [ ] **Step 1: Add route and dashboard entry** In `src/App.tsx` add: ```tsx import ProjectGraylog from "./components/projects/ProjectGraylog"; ``` ```tsx } /> ``` In `ProjectDashboard.tsx` add Graylog card: ```tsx
Graylog
Configure le polling Graylog et surveille les sujets scorés.
``` - [ ] **Step 2: Add Graylog live events on dashboard** In `ProjectDashboard.tsx` add listeners: ```tsx listen("graylog-polling-started", (event) => { const payload = event.payload as { project_id: string }; if (payload.project_id !== projectId) return; appendActivity("info", "Polling Graylog lancé."); }); listen("graylog-subject-triggered", (event) => { const payload = event.payload as { project_id: string; score: number; subject_id: string }; if (payload.project_id !== projectId) return; appendActivity("success", `Sujet Graylog déclenché (score ${payload.score}).`); void loadData(); }); listen("graylog-polling-error", (event) => { const payload = event.payload as { project_id: string; error: string }; if (payload.project_id !== projectId) return; appendActivity("error", `Erreur Graylog: ${payload.error}`); }); ``` - [ ] **Step 3: Create `ProjectGraylog.tsx` page** Create `src/components/projects/ProjectGraylog.tsx` with: ```tsx import { FormEvent, useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { getGraylogCredentials, listAgents, listGraylogDetections, listGraylogSubjects, manualGraylogPoll, setGraylogCredentials, testGraylogConnection, } from "../../lib/api"; import type { Agent, GraylogCredentialsSafe, GraylogDetection, GraylogSubject } from "../../lib/types"; import { getErrorMessage } from "../../lib/errors"; export default function ProjectGraylog() { const { projectId } = useParams<{ projectId: string }>(); const [agents, setAgents] = useState([]); const [credentials, setCredentials] = useState(null); const [subjects, setSubjects] = useState([]); const [detections, setDetections] = useState([]); const [baseUrl, setBaseUrl] = useState(""); const [apiToken, setApiToken] = useState(""); const [analystAgentId, setAnalystAgentId] = useState(""); const [developerAgentId, setDeveloperAgentId] = useState(""); const [streamId, setStreamId] = useState(""); const [queryFilter, setQueryFilter] = useState("level:(critical OR error OR warning)"); const [pollingIntervalMinutes, setPollingIntervalMinutes] = useState(10); const [lookbackMinutes, setLookbackMinutes] = useState(30); const [scoreThreshold, setScoreThreshold] = useState(70); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); async function refresh() { if (!projectId) return; setLoading(true); setError(null); try { const [agentList, creds, subjectList, detectionList] = await Promise.all([ listAgents(), getGraylogCredentials(projectId), listGraylogSubjects(projectId), listGraylogDetections(projectId), ]); setAgents(agentList); setCredentials(creds); setSubjects(subjectList); setDetections(detectionList); if (creds) { setBaseUrl(creds.base_url); setAnalystAgentId(creds.analyst_agent_id); setDeveloperAgentId(creds.developer_agent_id); setStreamId(creds.stream_id ?? ""); setQueryFilter(creds.query_filter); setPollingIntervalMinutes(creds.polling_interval_minutes); setLookbackMinutes(creds.lookback_minutes); setScoreThreshold(creds.score_threshold); } } catch (err: unknown) { setError(getErrorMessage(err)); } finally { setLoading(false); } } useEffect(() => { void refresh(); }, [projectId]); async function handleSave(event: FormEvent) { event.preventDefault(); if (!projectId) return; setSaving(true); setError(null); setSuccess(null); try { const saved = await setGraylogCredentials( projectId, baseUrl, apiToken, analystAgentId, developerAgentId, streamId.trim() || null, queryFilter, pollingIntervalMinutes, lookbackMinutes, scoreThreshold ); setCredentials(saved); setApiToken(""); setSuccess("Configuration Graylog sauvegardée."); await refresh(); } catch (err: unknown) { setError(getErrorMessage(err)); } finally { setSaving(false); } } async function handleTest() { if (!projectId) return; setError(null); setSuccess(null); try { const message = await testGraylogConnection(projectId); setSuccess(message); } catch (err: unknown) { setError(getErrorMessage(err)); } } async function handleManualPoll() { if (!projectId) return; setError(null); setSuccess(null); try { const triggered = await manualGraylogPoll(projectId); setSuccess(`Polling Graylog manuel terminé: ${triggered} sujet(s) déclenché(s).`); await refresh(); } catch (err: unknown) { setError(getErrorMessage(err)); } } const analysts = agents.filter((a) => a.role === "analyst"); const developers = agents.filter((a) => a.role === "developer"); return (

Graylog

{projectId && Retour}
{error &&
{error}
} {success &&
{success}
}

Configuration

setBaseUrl(e.target.value)} placeholder="https://graylog.example.com" required /> setApiToken(e.target.value)} placeholder={credentials ? "Laisser vide pour conserver le token actuel" : "Token API Graylog"} required={!credentials} />
setStreamId(e.target.value)} placeholder="stream_id (optionnel)" /> setQueryFilter(e.target.value)} />
setPollingIntervalMinutes(Number(e.target.value))} min={1} /> setLookbackMinutes(Number(e.target.value))} min={1} /> setScoreThreshold(Number(e.target.value))} min={1} max={100} />

Sujets détectés

{loading ?
Chargement...
: (
{subjects.map((subject) => (
{subject.source}
{subject.normalized_message}
Score: {subject.last_score} | Statut: {subject.status} | Last seen: {new Date(subject.last_seen_at).toLocaleString()}
))} {subjects.length === 0 &&
Aucun sujet détecté.
}
)}

Dernières détections

{detections.slice(0, 20).map((detection) => (
score={detection.score} total={detection.total_count} triggered={String(detection.triggered)} at={new Date(detection.created_at).toLocaleString()}
))} {detections.length === 0 &&
Aucune détection enregistrée.
}
); } ``` - [ ] **Step 4: Add Graylog shortcut in module cards** In `src/components/projects/ProjectModules.tsx`, inside each module card: ```tsx {projectId && mod.module_key === "graylog_polling_auto_resolve" && ( Configurer )} ``` - [ ] **Step 5: Add source awareness in ticket views** In `TicketList.tsx` ticket row header: ```tsx {ticket.source} ``` In `TicketDetail.tsx` info tab: ```tsx
Source: {ticket.source}
{ticket.source_ref && (
Source ref: {ticket.source_ref}
)} ``` - [ ] **Step 6: Run frontend QA and production build** Run: ```bash cd /home/leclere/Projets/IA/orchai npm run qa:frontend npm run build ``` Expected: PASS (typecheck + production bundle build). - [ ] **Step 7: Commit** ```bash cd /home/leclere/Projets/IA/orchai git add src/components/projects/ProjectGraylog.tsx src/components/projects/ProjectDashboard.tsx src/components/projects/ProjectModules.tsx src/components/tickets/TicketList.tsx src/components/tickets/TicketDetail.tsx src/App.tsx git commit -m "feat(ui): add graylog project page and live subject visibility" ``` --- ### Task 9: End-to-end verification and cleanup **Files:** - Modify: touched files containing QA fixes discovered in this task - [ ] **Step 1: Run full backend QA** Run: ```bash cd /home/leclere/Projets/IA/orchai npm run qa:backend:check npm run qa:backend:test ``` Expected: PASS. - [ ] **Step 2: Run full project QA** Run: ```bash cd /home/leclere/Projets/IA/orchai npm run qa ``` Expected: PASS with no clippy warnings. - [ ] **Step 3: Manual validation checklist** Run app: ```bash cd /home/leclere/Projets/IA/orchai npm run tauri dev ``` Validate: 1. Module `Polling Graylog + auto-resolve` appears in project modules. 2. Graylog config page saves credentials and tests connection. 3. Manual Graylog poll creates subject rows and detection history. 4. High-score subject creates one queue item only while active (strict dedup). 5. Ticket detail shows `source=graylog` and still supports retry/cancel path. - [ ] **Step 4: Final commit for follow-up fixes** ```bash cd /home/leclere/Projets/IA/orchai git status --short # If non-empty, continue: git add -A git commit -m "fix(graylog): complete integration polish and qa fixes" ``` --- ## Spec Coverage Checklist (self-review) 1. **Project-scoped credentials:** covered by Tasks 1, 3, 5, 8. 2. **Subject grouping (`source + normalized_message`):** covered by Task 4 and consumed in Task 6. 3. **Deterministic scoring (`severity + frequency + recency`):** covered by Task 4. 4. **Strict dedup (single active branch per subject):** covered by Task 6 tests and logic. 5. **Reuse existing `analyst -> developer` pipeline:** covered by Task 2 + Task 6 orchestrator source-aware updates. 6. **UI module/config/visibility:** covered by Tasks 7 and 8. 7. **Observability events (`graylog-*`):** covered by Tasks 6 and 8. 8. **Rollout safety via full QA:** covered by Task 9.