293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
import { useState, useMemo } from "react";
|
|
import { ShieldAlert, Download } from "lucide-react";
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
Cell,
|
|
} from "recharts";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { useAuditLogs } from "@/api/logs";
|
|
import type { AuditEntry } from "@/types/api";
|
|
|
|
const SENSITIVITY_COLORS: Record<string, string> = {
|
|
none: "#94a3b8",
|
|
low: "#60a5fa",
|
|
medium: "#f59e0b",
|
|
high: "#f97316",
|
|
critical: "#ef4444",
|
|
};
|
|
|
|
const PERIOD_OPTIONS = [
|
|
{ value: "7", label: "7 derniers jours" },
|
|
{ value: "30", label: "30 derniers jours" },
|
|
{ value: "90", label: "90 derniers jours" },
|
|
];
|
|
|
|
function getPeriodStart(days: number): string {
|
|
const d = new Date();
|
|
d.setUTCDate(d.getUTCDate() - days);
|
|
d.setUTCHours(0, 0, 0, 0); // Round to start of UTC day — stable queryKey, better caching
|
|
return d.toISOString();
|
|
}
|
|
|
|
function buildSensitivityChart(entries: AuditEntry[]) {
|
|
const counts: Record<string, number> = {
|
|
none: 0, low: 0, medium: 0, high: 0, critical: 0,
|
|
};
|
|
for (const e of entries) {
|
|
const key = e.sensitivity_level || "none";
|
|
counts[key] = (counts[key] ?? 0) + 1;
|
|
}
|
|
return Object.entries(counts).map(([name, count]) => ({ name, count }));
|
|
}
|
|
|
|
function buildTopUsers(entries: AuditEntry[], limit = 10) {
|
|
const map = new Map<string, { pii: number; requests: number }>();
|
|
for (const e of entries) {
|
|
const cur = map.get(e.user_id) ?? { pii: 0, requests: 0 };
|
|
cur.pii += e.pii_entity_count;
|
|
cur.requests += 1;
|
|
map.set(e.user_id, cur);
|
|
}
|
|
return [...map.entries()]
|
|
.sort((a, b) => b[1].pii - a[1].pii)
|
|
.slice(0, limit)
|
|
.map(([userId, stats]) => ({ userId, ...stats }));
|
|
}
|
|
|
|
function exportCSV(entries: AuditEntry[]) {
|
|
const headers = [
|
|
"timestamp", "user_id", "provider", "model_used",
|
|
"sensitivity_level", "pii_entity_count", "status", "cost_usd",
|
|
];
|
|
const rows = entries.map((e) =>
|
|
headers.map((h) => String(e[h as keyof AuditEntry] ?? "")).join(",")
|
|
);
|
|
const csv = [headers.join(","), ...rows].join("\n");
|
|
const blob = new Blob([csv], { type: "text/csv" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `security-logs-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export function SecurityPage() {
|
|
const [period, setPeriod] = useState("30");
|
|
// Memoised so queryKey stays stable across re-renders for the same period.
|
|
const start = useMemo(() => getPeriodStart(parseInt(period, 10)), [period]);
|
|
|
|
const { data, isLoading, error } = useAuditLogs({ start, limit: 500 }, 60_000);
|
|
|
|
const entries = data?.data ?? [];
|
|
const blockedEntries = entries.filter((e) => e.status !== "ok");
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-6 text-center">
|
|
<ShieldAlert className="h-8 w-8 text-destructive mx-auto mb-2" />
|
|
<p className="text-sm font-medium text-destructive">Impossible de charger les logs de sécurité</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{error instanceof Error ? error.message : "Erreur inconnue"}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
const chartData = buildSensitivityChart(entries);
|
|
const topUsers = buildTopUsers(entries);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
|
<ShieldAlert className="h-5 w-5 text-primary" />
|
|
Sécurité RSSI
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Vue consolidée des risques et données sensibles
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Select value={period} onValueChange={setPeriod}>
|
|
<SelectTrigger className="w-44">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{PERIOD_OPTIONS.map((o) => (
|
|
<SelectItem key={o.value} value={o.value}>
|
|
{o.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button variant="outline" size="sm" onClick={() => exportCSV(entries)} disabled={entries.length === 0}>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
Export CSV
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPIs */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{[
|
|
{ label: "Requêtes totales", value: isLoading ? "—" : entries.length.toLocaleString() },
|
|
{ label: "Requêtes bloquées", value: isLoading ? "—" : blockedEntries.length.toLocaleString() },
|
|
{
|
|
label: "Données PII détectées",
|
|
value: isLoading ? "—" : entries.reduce((s, e) => s + e.pii_entity_count, 0).toLocaleString(),
|
|
},
|
|
{
|
|
label: "Requêtes critique/haute",
|
|
value: isLoading
|
|
? "—"
|
|
: entries.filter((e) => e.sensitivity_level === "critical" || e.sensitivity_level === "high").length.toLocaleString(),
|
|
},
|
|
].map((kpi) => (
|
|
<div key={kpi.label} className="rounded-lg border bg-card p-4">
|
|
<p className="text-xs text-muted-foreground">{kpi.label}</p>
|
|
<p className="text-2xl font-bold mt-1">{kpi.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Sensitivity chart */}
|
|
<div className="rounded-lg border bg-card p-4">
|
|
<h3 className="text-sm font-semibold mb-4">Répartition par sensibilité</h3>
|
|
{isLoading ? (
|
|
<Skeleton className="h-48 w-full" />
|
|
) : (
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<BarChart data={chartData} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
|
<YAxis tick={{ fontSize: 12 }} />
|
|
<Tooltip />
|
|
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
|
{chartData.map((entry) => (
|
|
<Cell
|
|
key={entry.name}
|
|
fill={SENSITIVITY_COLORS[entry.name] ?? "#94a3b8"}
|
|
/>
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</div>
|
|
|
|
{/* Top users by PII count */}
|
|
<div className="rounded-lg border bg-card p-4">
|
|
<h3 className="text-sm font-semibold mb-4">Top 10 utilisateurs (données PII)</h3>
|
|
{isLoading ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-8 w-full" />
|
|
))}
|
|
</div>
|
|
) : topUsers.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-8">Aucune donnée</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{topUsers.map((u, idx) => (
|
|
<div key={u.userId} className="flex items-center justify-between text-sm">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="text-xs text-muted-foreground w-5 shrink-0">#{idx + 1}</span>
|
|
<span className="truncate font-mono text-xs">{u.userId}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Badge variant="outline" className="text-xs">{u.requests} req.</Badge>
|
|
<Badge variant="destructive" className="text-xs">{u.pii} PII</Badge>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Blocked requests timeline */}
|
|
<div className="rounded-lg border bg-card p-4">
|
|
<h3 className="text-sm font-semibold mb-4">
|
|
Requêtes bloquées / en erreur
|
|
{blockedEntries.length > 0 && (
|
|
<Badge variant="destructive" className="ml-2">{blockedEntries.length}</Badge>
|
|
)}
|
|
</h3>
|
|
{isLoading ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-10 w-full" />
|
|
))}
|
|
</div>
|
|
) : blockedEntries.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-6">
|
|
Aucune requête bloquée sur la période — système sain ✓
|
|
</p>
|
|
) : (
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Horodatage</TableHead>
|
|
<TableHead>Utilisateur</TableHead>
|
|
<TableHead>Modèle</TableHead>
|
|
<TableHead>Sensibilité</TableHead>
|
|
<TableHead>Erreur</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{blockedEntries.slice(0, 20).map((e) => (
|
|
<TableRow key={e.request_id}>
|
|
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
|
{new Date(e.timestamp).toLocaleString("fr-FR")}
|
|
</TableCell>
|
|
<TableCell className="text-xs font-mono">{e.user_id}</TableCell>
|
|
<TableCell className="text-xs">{e.model_used || e.model_requested}</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={
|
|
e.sensitivity_level === "critical" ? "destructive"
|
|
: e.sensitivity_level === "high" ? "warning"
|
|
: "outline"
|
|
}
|
|
>
|
|
{e.sensitivity_level || "—"}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">{e.error_type || e.status}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|