feat(graylog): add project UI and source-aware ticket views

This commit is contained in:
thibaud-lclr 2026-04-17 15:43:22 +02:00
parent d561578e78
commit f2be837a1e
9 changed files with 587 additions and 6 deletions

View file

@ -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();

View file

@ -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() {
<Route index element={<EmptyState />} />
<Route path="/projects/new" element={<ProjectForm />} />
<Route path="/projects/:projectId" element={<ProjectDashboard />} />
<Route path="/projects/:projectId/graylog" element={<ProjectGraylog />} />
<Route path="/projects/:projectId/modules" element={<ProjectModules />} />
<Route path="/projects/:projectId/live-agent" element={<ProjectLiveAgent />} />
<Route path="/projects/:projectId/tasks" element={<ProjectTasks />} />

View file

@ -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<PollingPayload>("polling-started", (event) => {
const payload = event.payload;
@ -202,6 +227,54 @@ export default function ProjectDashboard() {
);
void loadData();
}),
listen<GraylogPollingPayload>("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<GraylogSubjectTriggeredPayload>("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<GraylogPollingPayload>("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<GraylogPollingPayload>("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() {
<div className="mt-8">
<h3 className="text-lg font-semibold mb-4">Orchestrateur IA</h3>
<div className="grid gap-3 md:grid-cols-3">
<div className="grid gap-3 md:grid-cols-4">
<Link
to={`/projects/${project.id}/modules`}
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300"
@ -393,6 +474,15 @@ export default function ProjectDashboard() {
Crée une file de tâches traitées par des agents pré-définis.
</div>
</Link>
<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>
</div>
</div>

View file

@ -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<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);
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 (
<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="space-y-3 rounded-lg border border-gray-200 bg-white p-4"
>
<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={(event) => setBaseUrl(event.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={(event) => setApiToken(event.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={(event) => setAnalystAgentId(event.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={(event) => setDeveloperAgentId(event.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={(event) => setStreamId(event.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={(event) => setQueryFilter(event.target.value)}
placeholder="Filtre query Graylog"
/>
</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={(event) => setPollingIntervalMinutes(Number(event.target.value))}
min={1}
/>
<input
type="number"
className="rounded border border-gray-300 px-3 py-2 text-sm"
value={lookbackMinutes}
onChange={(event) => setLookbackMinutes(Number(event.target.value))}
min={1}
/>
<input
type="number"
className="rounded border border-gray-300 px-3 py-2 text-sm"
value={scoreThreshold}
onChange={(event) => setScoreThreshold(Number(event.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 handleTestConnection()}
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>
) : subjects.length === 0 ? (
<div className="text-sm text-gray-400">Aucun sujet détecté.</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>
))}
</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>
{detections.length === 0 ? (
<div className="text-sm text-gray-400">Aucune détection enregistrée.</div>
) : (
<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>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -72,6 +72,14 @@ export default function ProjectModules() {
<div className="text-sm font-semibold text-gray-900">{mod.name}</div>
<div className="mt-1 text-xs text-gray-500">{mod.description}</div>
<div className="mt-2 font-mono text-[11px] text-gray-400">{mod.module_key}</div>
{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>
)}
</div>
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
<input

View file

@ -300,6 +300,16 @@ export default function TicketDetail() {
<span className="text-sm text-gray-500">Status:</span>
<span className="ml-2 text-sm">{ticket.status}</span>
</div>
<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>
)}
<div>
<span className="text-sm text-gray-500">Detected:</span>
<span className="ml-2 text-sm">{new Date(ticket.detected_at).toLocaleString()}</span>

View file

@ -86,6 +86,9 @@ export default function TicketList() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-400">#{ticket.artifact_id}</span>
<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] uppercase tracking-wide text-gray-600">
{ticket.source}
</span>
<span className="truncate text-sm font-medium">{ticket.artifact_title}</span>
</div>
<div className="mt-1 text-xs text-gray-400">

View file

@ -18,6 +18,9 @@ import type {
LiveMessage,
LiveAgentExchange,
AgentTask,
GraylogCredentialsSafe,
GraylogSubject,
GraylogDetection,
} from "./types";
export async function createProject(
@ -107,6 +110,60 @@ export async function testTuleapConnection(projectId: string | null): Promise<st
return invoke("test_tuleap_connection", { projectId });
}
// Graylog credentials and polling
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 getGraylogCredentials(projectId: string): Promise<GraylogCredentialsSafe | null> {
return invoke("get_graylog_credentials", { projectId });
}
export async function deleteGraylogCredentials(projectId: string): Promise<void> {
return invoke("delete_graylog_credentials", { projectId });
}
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 });
}
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 });
}
// Trackers
export async function addTracker(
projectId: string,

View file

@ -67,7 +67,10 @@ export interface WatchedTracker {
export interface ProcessedTicket {
id: string;
tracker_id: string;
tracker_id: string | null;
project_id: string;
source: string;
source_ref: string | null;
artifact_id: number;
artifact_title: string;
artifact_data: string;
@ -80,6 +83,52 @@ export interface ProcessedTicket {
processed_at: string | null;
}
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;
created_at: string;
updated_at: string;
}
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;
created_at: string;
updated_at: 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;
}
export interface ProjectThroughputStats {
backlog_count: number;
done_last_24h: number;