From f2be837a1ed4f743805b062950fecbace5fc5ade Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Fri, 17 Apr 2026 15:43:22 +0200 Subject: [PATCH] feat(graylog): add project UI and source-aware ticket views --- src-tauri/src/models/worktree.rs | 27 +- src/App.tsx | 2 + src/components/projects/ProjectDashboard.tsx | 96 +++++- src/components/projects/ProjectGraylog.tsx | 339 +++++++++++++++++++ src/components/projects/ProjectModules.tsx | 8 + src/components/tickets/TicketDetail.tsx | 10 + src/components/tickets/TicketList.tsx | 3 + src/lib/api.ts | 57 ++++ src/lib/types.ts | 51 ++- 9 files changed, 587 insertions(+), 6 deletions(-) create mode 100644 src/components/projects/ProjectGraylog.tsx diff --git a/src-tauri/src/models/worktree.rs b/src-tauri/src/models/worktree.rs index 529b7f9..a52dda4 100644 --- a/src-tauri/src/models/worktree.rs +++ b/src-tauri/src/models/worktree.rs @@ -79,8 +79,7 @@ impl Worktree { w.created_at, w.merged_at, w.merged_into \ FROM worktrees w \ JOIN processed_tickets pt ON w.ticket_id = pt.id \ - JOIN watched_trackers wt ON pt.tracker_id = wt.id \ - WHERE wt.project_id = ?1 \ + WHERE pt.project_id = ?1 \ ORDER BY w.created_at DESC"; let mut stmt = conn.prepare(sql)?; let rows = stmt.query_map(params![project_id], from_row)?; @@ -199,6 +198,30 @@ mod tests { assert_eq!(worktrees.len(), 2); } + #[test] + fn test_list_by_project_includes_external_source_worktrees() { + let conn = db::init_in_memory().expect("db init"); + let project = Project::insert(&conn, "P1", "/path", None, "main").unwrap(); + + let ticket = ProcessedTicket::insert_external( + &conn, + &project.id, + "graylog", + Some("subject-1"), + -101, + "Graylog subject", + "{}", + ) + .expect("external ticket insert should succeed"); + + Worktree::insert(&conn, &ticket.id, "/wt-graylog", "orchai/graylog-101") + .expect("worktree insert should succeed"); + + let worktrees = Worktree::list_by_project(&conn, &project.id).unwrap(); + assert_eq!(worktrees.len(), 1); + assert_eq!(worktrees[0].ticket_id, ticket.id); + } + #[test] fn test_set_merged() { let (conn, ticket_id) = setup(); diff --git a/src/App.tsx b/src/App.tsx index f09c98a..abece75 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import AppLayout from "./components/layout/AppLayout"; import ProjectForm from "./components/projects/ProjectForm"; import ProjectDashboard from "./components/projects/ProjectDashboard"; +import ProjectGraylog from "./components/projects/ProjectGraylog"; import ProjectLiveAgent from "./components/projects/ProjectLiveAgent"; import ProjectModules from "./components/projects/ProjectModules"; import ProjectTasks from "./components/projects/ProjectTasks"; @@ -28,6 +29,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/projects/ProjectDashboard.tsx b/src/components/projects/ProjectDashboard.tsx index 8eb7faa..f936b03 100644 --- a/src/components/projects/ProjectDashboard.tsx +++ b/src/components/projects/ProjectDashboard.tsx @@ -44,6 +44,18 @@ interface TicketProcessingPayload { error?: string; } +interface GraylogPollingPayload { + project_id: string; + triggered_count?: number; + error?: string; +} + +interface GraylogSubjectTriggeredPayload { + project_id: string; + subject_id: string; + score: number; +} + export default function ProjectDashboard() { const { projectId } = useParams(); const navigate = useNavigate(); @@ -103,7 +115,20 @@ export default function ProjectDashboard() { async function setup() { try { - const [unlistenPollingStarted, unlistenPollingFinished, unlistenPollingError, unlistenTicketsDetected, unlistenTicketStarted, unlistenTicketDone, unlistenTicketError] = + const graylogPollKey = "__graylog__"; + const [ + unlistenPollingStarted, + unlistenPollingFinished, + unlistenPollingError, + unlistenTicketsDetected, + unlistenTicketStarted, + unlistenTicketDone, + unlistenTicketError, + unlistenGraylogPollingStarted, + unlistenGraylogSubjectTriggered, + unlistenGraylogPollingFinished, + unlistenGraylogPollingError, + ] = await Promise.all([ listen("polling-started", (event) => { const payload = event.payload; @@ -202,6 +227,54 @@ export default function ProjectDashboard() { ); void loadData(); }), + listen("graylog-polling-started", (event) => { + const payload = event.payload; + if (payload.project_id !== projectId) return; + + setActivePolls((prev) => ({ + ...prev, + [graylogPollKey]: "Graylog", + })); + appendActivity("info", "Polling Graylog lancé."); + }), + listen("graylog-subject-triggered", (event) => { + const payload = event.payload; + if (payload.project_id !== projectId) return; + + appendActivity( + "success", + `Sujet Graylog déclenché (score ${payload.score}).` + ); + void loadData(); + }), + listen("graylog-polling-finished", (event) => { + const payload = event.payload; + if (payload.project_id !== projectId) return; + + setActivePolls((prev) => { + const next = { ...prev }; + delete next[graylogPollKey]; + return next; + }); + appendActivity( + "success", + `Polling Graylog terminé (${payload.triggered_count ?? 0} sujet(s) déclenché(s)).` + ); + }), + listen("graylog-polling-error", (event) => { + const payload = event.payload; + if (payload.project_id !== projectId) return; + + setActivePolls((prev) => { + const next = { ...prev }; + delete next[graylogPollKey]; + return next; + }); + appendActivity( + "error", + `Erreur Graylog: ${payload.error ?? "erreur inconnue"}.` + ); + }), ]); if (cancelled) { @@ -212,6 +285,10 @@ export default function ProjectDashboard() { unlistenTicketStarted(); unlistenTicketDone(); unlistenTicketError(); + unlistenGraylogPollingStarted(); + unlistenGraylogSubjectTriggered(); + unlistenGraylogPollingFinished(); + unlistenGraylogPollingError(); return; } @@ -223,6 +300,10 @@ export default function ProjectDashboard() { unlistenTicketStarted, unlistenTicketDone, unlistenTicketError, + unlistenGraylogPollingStarted, + unlistenGraylogSubjectTriggered, + unlistenGraylogPollingFinished, + unlistenGraylogPollingError, ]; } catch (err) { appendActivity( @@ -240,7 +321,7 @@ export default function ProjectDashboard() { unlisten(); } }; - }, [projectId]); + }, [projectId, loadData]); async function handleDelete() { if (!projectId) return; @@ -365,7 +446,7 @@ export default function ProjectDashboard() {

Orchestrateur IA

-
+
+ +
Graylog
+
+ Configure le polling Graylog et surveille les sujets scorés. +
+
diff --git a/src/components/projects/ProjectGraylog.tsx b/src/components/projects/ProjectGraylog.tsx new file mode 100644 index 0000000..2ca1f3f --- /dev/null +++ b/src/components/projects/ProjectGraylog.tsx @@ -0,0 +1,339 @@ +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { + getGraylogCredentials, + listAgents, + listGraylogDetections, + listGraylogSubjects, + manualGraylogPoll, + setGraylogCredentials, + testGraylogConnection, +} from "../../lib/api"; +import { getErrorMessage } from "../../lib/errors"; +import type { + Agent, + GraylogCredentialsSafe, + GraylogDetection, + GraylogSubject, +} from "../../lib/types"; + +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); + + const analysts = agents.filter((agent) => agent.role === "analyst"); + const developers = agents.filter((agent) => agent.role === "developer"); + + const refresh = useCallback(async () => { + 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); + } + }, [projectId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + 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 handleTestConnection() { + 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 triggeredCount = await manualGraylogPoll(projectId); + setSuccess(`Polling Graylog manuel terminé: ${triggeredCount} sujet(s) déclenché(s).`); + await refresh(); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } + } + + return ( +
+
+

Graylog

+ {projectId && ( + + Retour + + )} +
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + +
+

Configuration

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

Sujets détectés

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

Dernières détections

+ + {detections.length === 0 ? ( +
Aucune détection enregistrée.
+ ) : ( +
+ {detections.slice(0, 20).map((detection) => ( +
+ score={detection.score} total={detection.total_count} triggered={" "} + {String(detection.triggered)} at={" "} + {new Date(detection.created_at).toLocaleString()} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/components/projects/ProjectModules.tsx b/src/components/projects/ProjectModules.tsx index 5873e50..e58aa26 100644 --- a/src/components/projects/ProjectModules.tsx +++ b/src/components/projects/ProjectModules.tsx @@ -72,6 +72,14 @@ export default function ProjectModules() {
{mod.name}
{mod.description}
{mod.module_key}
+ {projectId && mod.module_key === "graylog_polling_auto_resolve" && ( + + Configurer + + )}