482 lines
15 KiB
TypeScript
482 lines
15 KiB
TypeScript
import { FormEvent, useCallback, useEffect, useState } from "react";
|
|
import { Link, useParams } from "react-router-dom";
|
|
import {
|
|
getTuleapCredentials,
|
|
getGraylogCredentials,
|
|
listAgents,
|
|
listGraylogDetections,
|
|
listGraylogSubjects,
|
|
manualGraylogPoll,
|
|
setTuleapCredentials,
|
|
setGraylogCredentials,
|
|
testTuleapConnection,
|
|
testGraylogConnection,
|
|
} from "../../lib/api";
|
|
import { getErrorMessage } from "../../lib/errors";
|
|
import type {
|
|
Agent,
|
|
GraylogCredentialsSafe,
|
|
GraylogDetection,
|
|
GraylogSubject,
|
|
TuleapCredentialsSafe,
|
|
} from "../../lib/types";
|
|
import {
|
|
backLinkClass,
|
|
buttonClass,
|
|
cardContentClass,
|
|
inputClass,
|
|
noticeClass,
|
|
pageStackClass,
|
|
pageTitleClass,
|
|
} from "../ui/primitives";
|
|
|
|
export default function ProjectGraylog() {
|
|
const { projectId } = useParams<{ projectId: string }>();
|
|
|
|
const [agents, setAgents] = useState<Agent[]>([]);
|
|
const [tuleapCredentials, setTuleapCredentialsState] = useState<TuleapCredentialsSafe | null>(null);
|
|
const [credentials, setCredentials] = useState<GraylogCredentialsSafe | null>(null);
|
|
const [subjects, setSubjects] = useState<GraylogSubject[]>([]);
|
|
const [detections, setDetections] = useState<GraylogDetection[]>([]);
|
|
|
|
const [tuleapUrl, setTuleapUrl] = useState("");
|
|
const [tuleapUsername, setTuleapUsername] = useState("");
|
|
const [tuleapPassword, setTuleapPassword] = useState("");
|
|
const [baseUrl, setBaseUrl] = useState("");
|
|
const [apiToken, setApiToken] = useState("");
|
|
const [analystAgentId, setAnalystAgentId] = useState("");
|
|
const [developerAgentId, setDeveloperAgentId] = useState("");
|
|
const [reviewerAgentId, setReviewerAgentId] = 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 [savingTuleap, setSavingTuleap] = useState(false);
|
|
const [testingTuleap, setTestingTuleap] = 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 reviewers = agents.filter((agent) => agent.role === "reviewer");
|
|
const hasProjectScopedTuleapCredentials =
|
|
Boolean(projectId) && tuleapCredentials?.project_id === projectId;
|
|
const usingGlobalTuleapFallback =
|
|
Boolean(projectId) && Boolean(tuleapCredentials) && tuleapCredentials?.project_id !== projectId;
|
|
|
|
const refresh = useCallback(async () => {
|
|
if (!projectId) return;
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const [agentList, tuleapCreds, creds, subjectList, detectionList] = await Promise.all([
|
|
listAgents(),
|
|
getTuleapCredentials(projectId),
|
|
getGraylogCredentials(projectId),
|
|
listGraylogSubjects(projectId),
|
|
listGraylogDetections(projectId),
|
|
]);
|
|
|
|
setAgents(agentList);
|
|
setTuleapCredentialsState(tuleapCreds);
|
|
setCredentials(creds);
|
|
setSubjects(subjectList);
|
|
setDetections(detectionList);
|
|
|
|
if (tuleapCreds) {
|
|
setTuleapUrl(tuleapCreds.tuleap_url);
|
|
setTuleapUsername(tuleapCreds.username);
|
|
} else {
|
|
setTuleapUrl("");
|
|
setTuleapUsername("");
|
|
}
|
|
setTuleapPassword("");
|
|
|
|
if (creds) {
|
|
setBaseUrl(creds.base_url);
|
|
setAnalystAgentId(creds.analyst_agent_id);
|
|
setDeveloperAgentId(creds.developer_agent_id);
|
|
setReviewerAgentId(creds.reviewer_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 handleSaveTuleap(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (!projectId) return;
|
|
|
|
setSavingTuleap(true);
|
|
setError(null);
|
|
setSuccess(null);
|
|
|
|
try {
|
|
const saved = await setTuleapCredentials(projectId, tuleapUrl, tuleapUsername, tuleapPassword);
|
|
setTuleapCredentialsState(saved);
|
|
setTuleapPassword("");
|
|
setSuccess("Configuration Tuleap sauvegardée.");
|
|
} catch (err: unknown) {
|
|
setError(getErrorMessage(err));
|
|
} finally {
|
|
setSavingTuleap(false);
|
|
}
|
|
}
|
|
|
|
async function handleTestTuleap() {
|
|
if (!projectId) return;
|
|
|
|
setTestingTuleap(true);
|
|
setError(null);
|
|
setSuccess(null);
|
|
|
|
try {
|
|
const message = await testTuleapConnection(projectId);
|
|
setSuccess(message);
|
|
} catch (err: unknown) {
|
|
setError(getErrorMessage(err));
|
|
} finally {
|
|
setTestingTuleap(false);
|
|
}
|
|
}
|
|
|
|
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,
|
|
reviewerAgentId,
|
|
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={pageStackClass}>
|
|
<div>
|
|
{projectId && (
|
|
<Link to={`/projects/${projectId}`} className={backLinkClass}>
|
|
Back
|
|
</Link>
|
|
)}
|
|
<h2 className={pageTitleClass}>Intégrations</h2>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className={noticeClass("error")}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
{success && (
|
|
<div className={noticeClass("success")}>
|
|
{success}
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSaveTuleap} className={`space-y-3 ${cardContentClass}`}>
|
|
<h3 className="text-sm font-semibold text-gray-900">Configuration Tuleap</h3>
|
|
|
|
{usingGlobalTuleapFallback && (
|
|
<div className={noticeClass("warning")}>
|
|
Aucun credential Tuleap spécifique au projet n'est configuré. Le fallback global est utilisé.
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-xs text-gray-500">
|
|
{hasProjectScopedTuleapCredentials
|
|
? "Credentials Tuleap projet actifs."
|
|
: "Credentials utilisés pour les trackers Tuleap du projet."}
|
|
</div>
|
|
|
|
<input
|
|
className={inputClass}
|
|
type="url"
|
|
value={tuleapUrl}
|
|
onChange={(event) => setTuleapUrl(event.target.value)}
|
|
placeholder="https://tuleap.example.com"
|
|
required
|
|
/>
|
|
|
|
<input
|
|
className={inputClass}
|
|
type="text"
|
|
value={tuleapUsername}
|
|
onChange={(event) => setTuleapUsername(event.target.value)}
|
|
placeholder="Username Tuleap"
|
|
required
|
|
/>
|
|
|
|
<input
|
|
className={inputClass}
|
|
type="password"
|
|
value={tuleapPassword}
|
|
onChange={(event) => setTuleapPassword(event.target.value)}
|
|
placeholder="Mot de passe Tuleap"
|
|
required
|
|
/>
|
|
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="submit"
|
|
disabled={savingTuleap}
|
|
className={buttonClass({ variant: "primary" })}
|
|
>
|
|
{savingTuleap ? "Sauvegarde..." : "Sauvegarder"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleTestTuleap()}
|
|
disabled={testingTuleap || !tuleapCredentials}
|
|
className={buttonClass({ variant: "secondary" })}
|
|
>
|
|
{testingTuleap ? "Test..." : "Tester la connexion"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<form onSubmit={handleSave} className={`space-y-3 ${cardContentClass}`}>
|
|
<h3 className="text-sm font-semibold text-gray-900">Configuration Graylog</h3>
|
|
|
|
<input
|
|
className={inputClass}
|
|
value={baseUrl}
|
|
onChange={(event) => setBaseUrl(event.target.value)}
|
|
placeholder="https://graylog.example.com"
|
|
required
|
|
/>
|
|
|
|
<input
|
|
className={inputClass}
|
|
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-3">
|
|
<select
|
|
className={inputClass}
|
|
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={inputClass}
|
|
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>
|
|
<select
|
|
className={inputClass}
|
|
value={reviewerAgentId}
|
|
onChange={(event) => setReviewerAgentId(event.target.value)}
|
|
required
|
|
>
|
|
<option value="">Reviewer agent</option>
|
|
{reviewers.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={inputClass}
|
|
value={streamId}
|
|
onChange={(event) => setStreamId(event.target.value)}
|
|
placeholder="stream_id (optionnel)"
|
|
/>
|
|
<input
|
|
className={`${inputClass} 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={inputClass}
|
|
value={pollingIntervalMinutes}
|
|
onChange={(event) => setPollingIntervalMinutes(Number(event.target.value))}
|
|
min={1}
|
|
/>
|
|
<input
|
|
type="number"
|
|
className={inputClass}
|
|
value={lookbackMinutes}
|
|
onChange={(event) => setLookbackMinutes(Number(event.target.value))}
|
|
min={1}
|
|
/>
|
|
<input
|
|
type="number"
|
|
className={inputClass}
|
|
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={buttonClass({ variant: "primary" })}
|
|
>
|
|
{saving ? "Sauvegarde..." : "Sauvegarder"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleTestConnection()}
|
|
className={buttonClass({ variant: "secondary" })}
|
|
>
|
|
Tester la connexion
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleManualPoll()}
|
|
className={buttonClass({ variant: "neutralDark" })}
|
|
>
|
|
Poll manuel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div className={cardContentClass}>
|
|
<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={cardContentClass}>
|
|
<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>
|
|
);
|
|
}
|