diff --git a/docs/superpowers/plans/2026-04-17-graylog-auto-resolve-implementation.md b/docs/superpowers/plans/2026-04-17-graylog-auto-resolve-implementation.md new file mode 100644 index 0000000..afda0c9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-graylog-auto-resolve-implementation.md @@ -0,0 +1,2027 @@ +# 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.