feat(graylog): add project UI and source-aware ticket views
This commit is contained in:
parent
d561578e78
commit
f2be837a1e
9 changed files with 587 additions and 6 deletions
|
|
@ -79,8 +79,7 @@ impl Worktree {
|
||||||
w.created_at, w.merged_at, w.merged_into \
|
w.created_at, w.merged_at, w.merged_into \
|
||||||
FROM worktrees w \
|
FROM worktrees w \
|
||||||
JOIN processed_tickets pt ON w.ticket_id = pt.id \
|
JOIN processed_tickets pt ON w.ticket_id = pt.id \
|
||||||
JOIN watched_trackers wt ON pt.tracker_id = wt.id \
|
WHERE pt.project_id = ?1 \
|
||||||
WHERE wt.project_id = ?1 \
|
|
||||||
ORDER BY w.created_at DESC";
|
ORDER BY w.created_at DESC";
|
||||||
let mut stmt = conn.prepare(sql)?;
|
let mut stmt = conn.prepare(sql)?;
|
||||||
let rows = stmt.query_map(params![project_id], from_row)?;
|
let rows = stmt.query_map(params![project_id], from_row)?;
|
||||||
|
|
@ -199,6 +198,30 @@ mod tests {
|
||||||
assert_eq!(worktrees.len(), 2);
|
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]
|
#[test]
|
||||||
fn test_set_merged() {
|
fn test_set_merged() {
|
||||||
let (conn, ticket_id) = setup();
|
let (conn, ticket_id) = setup();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import AppLayout from "./components/layout/AppLayout";
|
import AppLayout from "./components/layout/AppLayout";
|
||||||
import ProjectForm from "./components/projects/ProjectForm";
|
import ProjectForm from "./components/projects/ProjectForm";
|
||||||
import ProjectDashboard from "./components/projects/ProjectDashboard";
|
import ProjectDashboard from "./components/projects/ProjectDashboard";
|
||||||
|
import ProjectGraylog from "./components/projects/ProjectGraylog";
|
||||||
import ProjectLiveAgent from "./components/projects/ProjectLiveAgent";
|
import ProjectLiveAgent from "./components/projects/ProjectLiveAgent";
|
||||||
import ProjectModules from "./components/projects/ProjectModules";
|
import ProjectModules from "./components/projects/ProjectModules";
|
||||||
import ProjectTasks from "./components/projects/ProjectTasks";
|
import ProjectTasks from "./components/projects/ProjectTasks";
|
||||||
|
|
@ -28,6 +29,7 @@ function App() {
|
||||||
<Route index element={<EmptyState />} />
|
<Route index element={<EmptyState />} />
|
||||||
<Route path="/projects/new" element={<ProjectForm />} />
|
<Route path="/projects/new" element={<ProjectForm />} />
|
||||||
<Route path="/projects/:projectId" element={<ProjectDashboard />} />
|
<Route path="/projects/:projectId" element={<ProjectDashboard />} />
|
||||||
|
<Route path="/projects/:projectId/graylog" element={<ProjectGraylog />} />
|
||||||
<Route path="/projects/:projectId/modules" element={<ProjectModules />} />
|
<Route path="/projects/:projectId/modules" element={<ProjectModules />} />
|
||||||
<Route path="/projects/:projectId/live-agent" element={<ProjectLiveAgent />} />
|
<Route path="/projects/:projectId/live-agent" element={<ProjectLiveAgent />} />
|
||||||
<Route path="/projects/:projectId/tasks" element={<ProjectTasks />} />
|
<Route path="/projects/:projectId/tasks" element={<ProjectTasks />} />
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,18 @@ interface TicketProcessingPayload {
|
||||||
error?: string;
|
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() {
|
export default function ProjectDashboard() {
|
||||||
const { projectId } = useParams();
|
const { projectId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -103,7 +115,20 @@ export default function ProjectDashboard() {
|
||||||
|
|
||||||
async function setup() {
|
async function setup() {
|
||||||
try {
|
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([
|
await Promise.all([
|
||||||
listen<PollingPayload>("polling-started", (event) => {
|
listen<PollingPayload>("polling-started", (event) => {
|
||||||
const payload = event.payload;
|
const payload = event.payload;
|
||||||
|
|
@ -202,6 +227,54 @@ export default function ProjectDashboard() {
|
||||||
);
|
);
|
||||||
void loadData();
|
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) {
|
if (cancelled) {
|
||||||
|
|
@ -212,6 +285,10 @@ export default function ProjectDashboard() {
|
||||||
unlistenTicketStarted();
|
unlistenTicketStarted();
|
||||||
unlistenTicketDone();
|
unlistenTicketDone();
|
||||||
unlistenTicketError();
|
unlistenTicketError();
|
||||||
|
unlistenGraylogPollingStarted();
|
||||||
|
unlistenGraylogSubjectTriggered();
|
||||||
|
unlistenGraylogPollingFinished();
|
||||||
|
unlistenGraylogPollingError();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,6 +300,10 @@ export default function ProjectDashboard() {
|
||||||
unlistenTicketStarted,
|
unlistenTicketStarted,
|
||||||
unlistenTicketDone,
|
unlistenTicketDone,
|
||||||
unlistenTicketError,
|
unlistenTicketError,
|
||||||
|
unlistenGraylogPollingStarted,
|
||||||
|
unlistenGraylogSubjectTriggered,
|
||||||
|
unlistenGraylogPollingFinished,
|
||||||
|
unlistenGraylogPollingError,
|
||||||
];
|
];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
appendActivity(
|
appendActivity(
|
||||||
|
|
@ -240,7 +321,7 @@ export default function ProjectDashboard() {
|
||||||
unlisten();
|
unlisten();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [projectId]);
|
}, [projectId, loadData]);
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
@ -365,7 +446,7 @@ export default function ProjectDashboard() {
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h3 className="text-lg font-semibold mb-4">Orchestrateur IA</h3>
|
<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
|
<Link
|
||||||
to={`/projects/${project.id}/modules`}
|
to={`/projects/${project.id}/modules`}
|
||||||
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300"
|
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.
|
Crée une file de tâches traitées par des agents pré-définis.
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
339
src/components/projects/ProjectGraylog.tsx
Normal file
339
src/components/projects/ProjectGraylog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,14 @@ export default function ProjectModules() {
|
||||||
<div className="text-sm font-semibold text-gray-900">{mod.name}</div>
|
<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-1 text-xs text-gray-500">{mod.description}</div>
|
||||||
<div className="mt-2 font-mono text-[11px] text-gray-400">{mod.module_key}</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>
|
</div>
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
|
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,16 @@ export default function TicketDetail() {
|
||||||
<span className="text-sm text-gray-500">Status:</span>
|
<span className="text-sm text-gray-500">Status:</span>
|
||||||
<span className="ml-2 text-sm">{ticket.status}</span>
|
<span className="ml-2 text-sm">{ticket.status}</span>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<span className="text-sm text-gray-500">Detected:</span>
|
<span className="text-sm text-gray-500">Detected:</span>
|
||||||
<span className="ml-2 text-sm">{new Date(ticket.detected_at).toLocaleString()}</span>
|
<span className="ml-2 text-sm">{new Date(ticket.detected_at).toLocaleString()}</span>
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,9 @@ export default function TicketList() {
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-mono text-xs text-gray-400">#{ticket.artifact_id}</span>
|
<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>
|
<span className="truncate text-sm font-medium">{ticket.artifact_title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-gray-400">
|
<div className="mt-1 text-xs text-gray-400">
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import type {
|
||||||
LiveMessage,
|
LiveMessage,
|
||||||
LiveAgentExchange,
|
LiveAgentExchange,
|
||||||
AgentTask,
|
AgentTask,
|
||||||
|
GraylogCredentialsSafe,
|
||||||
|
GraylogSubject,
|
||||||
|
GraylogDetection,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export async function createProject(
|
export async function createProject(
|
||||||
|
|
@ -107,6 +110,60 @@ export async function testTuleapConnection(projectId: string | null): Promise<st
|
||||||
return invoke("test_tuleap_connection", { projectId });
|
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
|
// Trackers
|
||||||
export async function addTracker(
|
export async function addTracker(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,10 @@ export interface WatchedTracker {
|
||||||
|
|
||||||
export interface ProcessedTicket {
|
export interface ProcessedTicket {
|
||||||
id: string;
|
id: string;
|
||||||
tracker_id: string;
|
tracker_id: string | null;
|
||||||
|
project_id: string;
|
||||||
|
source: string;
|
||||||
|
source_ref: string | null;
|
||||||
artifact_id: number;
|
artifact_id: number;
|
||||||
artifact_title: string;
|
artifact_title: string;
|
||||||
artifact_data: string;
|
artifact_data: string;
|
||||||
|
|
@ -80,6 +83,52 @@ export interface ProcessedTicket {
|
||||||
processed_at: string | null;
|
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 {
|
export interface ProjectThroughputStats {
|
||||||
backlog_count: number;
|
backlog_count: number;
|
||||||
done_last_24h: number;
|
done_last_24h: number;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue