65 KiB
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
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:
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",
]
);
assert_eq!(version, 9);
- Step 2: Run DB tests to verify failure before migration wiring
Run:
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:
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:
const MIGRATION_009: &str = include_str!("../migrations/009_graylog_auto_resolve.sql");
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:
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
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:
#[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:
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:
pub struct ProcessedTicket {
pub id: String,
pub tracker_id: Option<String>,
pub project_id: String,
pub source: String,
pub source_ref: Option<String>,
pub artifact_id: i32,
pub artifact_title: String,
pub artifact_data: String,
pub status: String,
pub analyst_report: Option<String>,
pub developer_report: Option<String>,
pub worktree_path: Option<String>,
pub branch_name: Option<String>,
pub detected_at: String,
pub processed_at: Option<String>,
}
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";
pub fn insert_if_new(
conn: &Connection,
project_id: &str,
tracker_id: &str,
artifact_id: i32,
artifact_title: &str,
artifact_data: &str,
) -> Result<Option<ProcessedTicket>> {
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,
}))
}
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<ProcessedTicket> {
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,
})
}
pub fn list_by_project(conn: &Connection, project_id: &str) -> Result<Vec<ProcessedTicket>> {
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()
}
pub fn get_project_throughput_stats(
conn: &Connection,
project_id: &str,
) -> Result<ProjectThroughputStats> {
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.rssrc-tauri/src/commands/poller.rs
Snippet to apply at both call sites:
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:
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
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:
#[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.rswith credentials, subjects, detections
Create src-tauri/src/models/graylog.rs with these core structs and methods:
#[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<String>,
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,
}
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<GraylogCredentials> {
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)
}
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<GraylogSubject> {
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:
pub mod graylog;
In src-tauri/src/models/module.rs:
pub const MODULE_GRAYLOG_AUTO_RESOLVE: &str = "graylog_polling_auto_resolve";
And add default insertion:
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:
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
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:
#[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("<num>"));
assert!(normalized.contains("<ip>"));
assert!(normalized.contains("<ts>"));
}
#[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:
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<String>,
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<serde_json::Value>,
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, "<uuid>").to_string();
value = ip_re.replace_all(&value, "<ip>").to_string();
value = ts_re.replace_all(&value, "<ts>").to_string();
value = hash_re.replace_all(&value, "<hash>").to_string();
value = num_re.replace_all(&value, "<num>").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<chrono::Utc>) -> Vec<SubjectAggregate> {
let mut map: HashMap<String, SubjectAggregate> = 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<SubjectAggregate> = 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]:
regex = "1"
urlencoding = "2"
- Step 4: Export service module
In src-tauri/src/services/mod.rs:
pub mod graylog_scoring;
- Step 5: Run scoring tests
Run:
cd /home/leclere/Projets/IA/orchai/src-tauri
cargo test --lib services::graylog_scoring -- --nocapture
Expected: PASS.
- Step 6: Commit
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:
#[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:
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<reqwest::Response, String> {
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<Vec<GraylogEvent>, 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<GraylogEvent> {
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:
#[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<String>,
query_filter: String,
polling_interval_minutes: i32,
lookback_minutes: i32,
score_threshold: i32,
) -> Result<GraylogCredentialsSafe, AppError> {
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<String, AppError> {
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<i32, AppError> {
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:
pub mod graylog;
In src-tauri/src/services/mod.rs:
pub mod graylog_client;
In src-tauri/src/lib.rs invoke handler:
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:
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
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:
#[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:
fn should_trigger_subject(score: i32, threshold: i32, has_active_ticket: bool) -> bool {
score >= threshold && !has_active_ticket
}
- Step 2: Implement
graylog_poller.rsservice
Create src-tauri/src/services/graylog_poller.rs:
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:
pub async fn poll_project_once(
db: &Arc<Mutex<Connection>>,
encryption_key: &[u8; 32],
http_client: &reqwest::Client,
app_handle: &AppHandle,
project_id: &str,
) -> Result<i32, String> {
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:
pub mod graylog_poller;
In src-tauri/src/lib.rs setup:
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:
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:
let project = crate::models::project::Project::get_by_id(&conn, &ticket.project_id)?;
- Step 5: Run service and orchestrator tests
Run:
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
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:
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:
tracker_id: string | null;
project_id: string;
source: string;
source_ref: string | null;
- Step 2: Add Graylog API wrappers
In src/lib/api.ts:
export async function getGraylogCredentials(projectId: string): Promise<GraylogCredentialsSafe | null> {
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<GraylogCredentialsSafe> {
return invoke("set_graylog_credentials", {
projectId,
baseUrl,
apiToken,
analystAgentId,
developerAgentId,
streamId,
queryFilter,
pollingIntervalMinutes,
lookbackMinutes,
scoreThreshold,
});
}
export async function listGraylogSubjects(projectId: string): Promise<GraylogSubject[]> {
return invoke("list_graylog_subjects", { projectId });
}
export async function listGraylogDetections(projectId: string, subjectId?: string): Promise<GraylogDetection[]> {
return invoke("list_graylog_detections", { projectId, subjectId });
}
export async function testGraylogConnection(projectId: string): Promise<string> {
return invoke("test_graylog_connection", { projectId });
}
export async function manualGraylogPoll(projectId: string): Promise<number> {
return invoke("manual_graylog_poll", { projectId });
}
- Step 3: Run frontend typecheck
Run:
cd /home/leclere/Projets/IA/orchai
npm run qa:frontend
Expected: PASS.
- Step 4: Commit
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:
import ProjectGraylog from "./components/projects/ProjectGraylog";
<Route path="/projects/:projectId/graylog" element={<ProjectGraylog />} />
In ProjectDashboard.tsx add Graylog card:
<Link
to={`/projects/${project.id}/graylog`}
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300"
>
<div className="text-sm font-semibold text-gray-900">Graylog</div>
<div className="mt-1 text-xs text-gray-500">
Configure le polling Graylog et surveille les sujets scorés.
</div>
</Link>
- Step 2: Add Graylog live events on dashboard
In ProjectDashboard.tsx add listeners:
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.tsxpage
Create src/components/projects/ProjectGraylog.tsx with:
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<Agent[]>([]);
const [credentials, setCredentials] = useState<GraylogCredentialsSafe | null>(null);
const [subjects, setSubjects] = useState<GraylogSubject[]>([]);
const [detections, setDetections] = useState<GraylogDetection[]>([]);
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<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<div className="space-y-6 p-8">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Graylog</h2>
{projectId && <Link to={`/projects/${projectId}`} className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-300">Retour</Link>}
</div>
{error && <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
{success && <div className="rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">{success}</div>}
<form onSubmit={handleSave} className="rounded-lg border border-gray-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-900">Configuration</h3>
<input className="w-full rounded border border-gray-300 px-3 py-2 text-sm" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://graylog.example.com" required />
<input className="w-full rounded border border-gray-300 px-3 py-2 text-sm" value={apiToken} onChange={(e) => setApiToken(e.target.value)} placeholder={credentials ? "Laisser vide pour conserver le token actuel" : "Token API Graylog"} required={!credentials} />
<div className="grid gap-3 md:grid-cols-2">
<select className="rounded border border-gray-300 px-3 py-2 text-sm" value={analystAgentId} onChange={(e) => setAnalystAgentId(e.target.value)} required>
<option value="">Analyst agent</option>
{analysts.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
</select>
<select className="rounded border border-gray-300 px-3 py-2 text-sm" value={developerAgentId} onChange={(e) => setDeveloperAgentId(e.target.value)} required>
<option value="">Developer agent</option>
{developers.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
</select>
</div>
<div className="grid gap-3 md:grid-cols-4">
<input className="rounded border border-gray-300 px-3 py-2 text-sm" value={streamId} onChange={(e) => setStreamId(e.target.value)} placeholder="stream_id (optionnel)" />
<input className="rounded border border-gray-300 px-3 py-2 text-sm md:col-span-3" value={queryFilter} onChange={(e) => setQueryFilter(e.target.value)} />
</div>
<div className="grid gap-3 md:grid-cols-3">
<input type="number" className="rounded border border-gray-300 px-3 py-2 text-sm" value={pollingIntervalMinutes} onChange={(e) => setPollingIntervalMinutes(Number(e.target.value))} min={1} />
<input type="number" className="rounded border border-gray-300 px-3 py-2 text-sm" value={lookbackMinutes} onChange={(e) => setLookbackMinutes(Number(e.target.value))} min={1} />
<input type="number" className="rounded border border-gray-300 px-3 py-2 text-sm" value={scoreThreshold} onChange={(e) => setScoreThreshold(Number(e.target.value))} min={1} max={100} />
</div>
<div className="flex gap-2">
<button type="submit" disabled={saving} className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50">{saving ? "Sauvegarde..." : "Sauvegarder"}</button>
<button type="button" onClick={() => void handleTest()} className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-800 hover:bg-gray-300">Tester la connexion</button>
<button type="button" onClick={() => void handleManualPoll()} className="rounded bg-gray-900 px-4 py-2 text-sm text-white hover:bg-black">Poll manuel</button>
</div>
</form>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-3 text-sm font-semibold text-gray-900">Sujets détectés</h3>
{loading ? <div className="text-sm text-gray-500">Chargement...</div> : (
<div className="space-y-2">
{subjects.map((subject) => (
<div key={subject.id} className="rounded border border-gray-100 p-3">
<div className="text-sm font-medium text-gray-900">{subject.source}</div>
<div className="text-xs text-gray-600">{subject.normalized_message}</div>
<div className="mt-1 text-xs text-gray-500">Score: {subject.last_score} | Statut: {subject.status} | Last seen: {new Date(subject.last_seen_at).toLocaleString()}</div>
</div>
))}
{subjects.length === 0 && <div className="text-sm text-gray-400">Aucun sujet détecté.</div>}
</div>
)}
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-3 text-sm font-semibold text-gray-900">Dernières détections</h3>
<div className="space-y-2">
{detections.slice(0, 20).map((detection) => (
<div key={detection.id} className="rounded border border-gray-100 p-3 text-xs text-gray-700">
score={detection.score} total={detection.total_count} triggered={String(detection.triggered)} at={new Date(detection.created_at).toLocaleString()}
</div>
))}
{detections.length === 0 && <div className="text-sm text-gray-400">Aucune détection enregistrée.</div>}
</div>
</div>
</div>
);
}
- Step 4: Add Graylog shortcut in module cards
In src/components/projects/ProjectModules.tsx, inside each module card:
{projectId && mod.module_key === "graylog_polling_auto_resolve" && (
<Link
to={`/projects/${projectId}/graylog`}
className="mt-2 inline-flex rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200"
>
Configurer
</Link>
)}
- Step 5: Add source awareness in ticket views
In TicketList.tsx ticket row header:
<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] uppercase tracking-wide text-gray-600">
{ticket.source}
</span>
In TicketDetail.tsx info tab:
<div>
<span className="text-sm text-gray-500">Source:</span>
<span className="ml-2 text-sm uppercase">{ticket.source}</span>
</div>
{ticket.source_ref && (
<div>
<span className="text-sm text-gray-500">Source ref:</span>
<span className="ml-2 font-mono text-sm">{ticket.source_ref}</span>
</div>
)}
- Step 6: Run frontend QA and production build
Run:
cd /home/leclere/Projets/IA/orchai
npm run qa:frontend
npm run build
Expected: PASS (typecheck + production bundle build).
- Step 7: Commit
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:
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:
cd /home/leclere/Projets/IA/orchai
npm run qa
Expected: PASS with no clippy warnings.
- Step 3: Manual validation checklist
Run app:
cd /home/leclere/Projets/IA/orchai
npm run tauri dev
Validate:
- Module
Polling Graylog + auto-resolveappears in project modules. - Graylog config page saves credentials and tests connection.
- Manual Graylog poll creates subject rows and detection history.
- High-score subject creates one queue item only while active (strict dedup).
- Ticket detail shows
source=graylogand still supports retry/cancel path.
- Step 4: Final commit for follow-up fixes
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)
- Project-scoped credentials: covered by Tasks 1, 3, 5, 8.
- Subject grouping (
source + normalized_message): covered by Task 4 and consumed in Task 6. - Deterministic scoring (
severity + frequency + recency): covered by Task 4. - Strict dedup (single active branch per subject): covered by Task 6 tests and logic.
- Reuse existing
analyst -> developerpipeline: covered by Task 2 + Task 6 orchestrator source-aware updates. - UI module/config/visibility: covered by Tasks 7 and 8.
- Observability events (
graylog-*): covered by Tasks 6 and 8. - Rollout safety via full QA: covered by Task 9.