veylant/web/src/pages/SecurityPage.tsx
2026-02-23 13:35:04 +01:00

279 lines
9.9 KiB
TypeScript

import { useState } 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.setDate(d.getDate() - days);
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");
const start = getPeriodStart(parseInt(period, 10));
const { data, isLoading } = useAuditLogs({ start, limit: 500 }, 60_000);
const entries = data?.data ?? [];
const blockedEntries = entries.filter((e) => e.status !== "ok");
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>
);
}