feat(ai-training): ajout validation OpenCV, gestion des logs et modération des contributions
- Backend IA : - Intégration d'OpenCV (WebAssembly) pour la détection et certification des cibles (anneaux concentriques, confiance). - Ajout du système de journalisation complet des uploads (SQLite, IP, appareil, statut, métadonnées). - Implémentation du système de modération (bannissement/débannissement de wallets, rejet 403 avec motifs). - Harmonisation des codes d'erreur et messages JSON (UPLOAD_SUCCESS, WALLET_BANNED, etc.). - Dashboard Next.js : page dédiée aux logs avec filtres, export CSV et actions de modération en un clic. - Application Mobile (Flutter) : - Encapsulation des réponses d'export dans AiExportResult avec gestion fine des erreurs et statuts. - Mise à jour du disclaimer de participation à l'entraînement IA avec avertissements stricts de bannissement. - Révocation de l'envoi de photos et affichage d'un bandeau explicatif en cas de suspension. - Ajout d'un bouton manuel « Actualiser mon statut » dans les paramètres pour synchroniser l'état du compte. - Possibilité de configurer l'URL/IP du serveur IA directement depuis les paramètres.
This commit is contained in:
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Link from "next/link";
|
||||
import { LayoutDashboard, Users, Image as ImageIcon } from "lucide-react";
|
||||
import { LayoutDashboard, Users, ScrollText, Image as ImageIcon } from "lucide-react";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -41,6 +41,13 @@ export default function RootLayout({
|
||||
<Users size={20} />
|
||||
Contributeurs
|
||||
</Link>
|
||||
<Link
|
||||
href="/logs"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
|
||||
>
|
||||
<ScrollText size={20} />
|
||||
Logs d'Uploads
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-6 border-t border-slate-800">
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
ScrollText,
|
||||
Search,
|
||||
Download,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Copy,
|
||||
Check,
|
||||
Trash2,
|
||||
Smartphone,
|
||||
Crosshair,
|
||||
Calendar,
|
||||
HardDrive,
|
||||
Users,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Filter,
|
||||
Eye,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Ban,
|
||||
AlertTriangle
|
||||
} from "lucide-react";
|
||||
import { API_BASE_URL } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
|
||||
interface UploadLog {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
session_id: string | null;
|
||||
wallet_hash: string | null;
|
||||
image_filename: string | null;
|
||||
json_filename: string | null;
|
||||
file_size: number | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
device_model: string | null;
|
||||
device_os: string | null;
|
||||
target_type: string | null;
|
||||
weapon: string | null;
|
||||
distance_meters: number | null;
|
||||
impacts_count: number;
|
||||
status: string;
|
||||
target_valid?: number;
|
||||
target_status?: string;
|
||||
target_confidence?: number;
|
||||
target_rings_count?: number;
|
||||
target_details?: string | null;
|
||||
error_message: string | null;
|
||||
raw_metadata: string | null;
|
||||
}
|
||||
|
||||
interface BannedWallet {
|
||||
wallet_hash: string;
|
||||
reason: string;
|
||||
banned_at: string;
|
||||
banned_by: string;
|
||||
}
|
||||
|
||||
interface LogStats {
|
||||
total_uploads: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
today_uploads: number;
|
||||
unique_wallets: number;
|
||||
total_bytes: number;
|
||||
latest_upload: string | null;
|
||||
}
|
||||
|
||||
export default function LogsManager({
|
||||
initialLogs,
|
||||
initialStats
|
||||
}: {
|
||||
initialLogs: UploadLog[];
|
||||
initialStats: LogStats | null;
|
||||
}) {
|
||||
const [logs, setLogs] = useState<UploadLog[]>(initialLogs);
|
||||
const [stats, setStats] = useState<LogStats | null>(initialStats);
|
||||
const [bannedWallets, setBannedWallets] = useState<BannedWallet[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | "SUCCESS" | "FAILED" | "INVALID_TARGET" | "BANNED">("ALL");
|
||||
const [copiedWallet, setCopiedWallet] = useState<string | null>(null);
|
||||
const [selectedLog, setSelectedLog] = useState<UploadLog | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
|
||||
// Moderation state
|
||||
const [banModalWallet, setBanModalWallet] = useState<string | null>(null);
|
||||
const [banReason, setBanReason] = useState("Images non conformes / Fausse cible");
|
||||
const [isSubmittingBan, setIsSubmittingBan] = useState(false);
|
||||
|
||||
const fetchBannedWallets = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/moderation/banned`, { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBannedWallets(data.banned || []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur récupération wallets bannis:", e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchBannedWallets();
|
||||
}, []);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [logsRes, statsRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/logs?limit=200`, { cache: 'no-store' }),
|
||||
fetch(`${API_BASE_URL}/api/logs/stats`, { cache: 'no-store' }),
|
||||
fetchBannedWallets()
|
||||
]);
|
||||
|
||||
if (logsRes.ok) {
|
||||
const logsData = await logsRes.json();
|
||||
setLogs(logsData.logs || []);
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
const statsData = await statsRes.json();
|
||||
setStats(statsData.stats || null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur lors de la récupération des logs:", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedWallet(text);
|
||||
setTimeout(() => setCopiedWallet(null), 2000);
|
||||
};
|
||||
|
||||
const isWalletBanned = (walletHash: string | null) => {
|
||||
if (!walletHash) return false;
|
||||
return bannedWallets.some(b => b.wallet_hash === walletHash);
|
||||
};
|
||||
|
||||
const getBannedInfo = (walletHash: string | null) => {
|
||||
if (!walletHash) return null;
|
||||
return bannedWallets.find(b => b.wallet_hash === walletHash) || null;
|
||||
};
|
||||
|
||||
const handleBanWallet = async () => {
|
||||
if (!banModalWallet) return;
|
||||
setIsSubmittingBan(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/moderation/ban`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
wallet_hash: banModalWallet,
|
||||
reason: banReason,
|
||||
banned_by: 'Admin Dashboard'
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
setBanModalWallet(null);
|
||||
await fetchBannedWallets();
|
||||
await fetchLogs();
|
||||
} else {
|
||||
alert("Erreur lors du bannissement.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur ban:", e);
|
||||
} finally {
|
||||
setIsSubmittingBan(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbanWallet = async (walletHash: string) => {
|
||||
if (!confirm(`Voulez-vous vraiment débannir le wallet ${walletHash.substring(0, 10)}... ?`)) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/moderation/ban/${walletHash}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchBannedWallets();
|
||||
await fetchLogs();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur unban:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLog = async (id: number) => {
|
||||
if (!confirm("Voulez-vous vraiment supprimer ce log ?")) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/logs/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
setLogs(prev => prev.filter(l => l.id !== id));
|
||||
fetchLogs();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur suppression:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/logs`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
setLogs([]);
|
||||
setShowClearConfirm(false);
|
||||
fetchLogs();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur vidage logs:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number | null) => {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
return logs.filter(log => {
|
||||
// Filter status
|
||||
if (statusFilter === "SUCCESS" && log.status !== "SUCCESS") return false;
|
||||
if (statusFilter === "FAILED" && log.status !== "FAILED") return false;
|
||||
if (statusFilter === "INVALID_TARGET" && log.target_status === "VALID") return false;
|
||||
if (statusFilter === "BANNED" && !isWalletBanned(log.wallet_hash)) return false;
|
||||
|
||||
// Filter search
|
||||
if (!searchTerm.trim()) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
(log.wallet_hash && log.wallet_hash.toLowerCase().includes(term)) ||
|
||||
(log.session_id && log.session_id.toLowerCase().includes(term)) ||
|
||||
(log.image_filename && log.image_filename.toLowerCase().includes(term)) ||
|
||||
(log.device_model && log.device_model.toLowerCase().includes(term)) ||
|
||||
(log.weapon && log.weapon.toLowerCase().includes(term)) ||
|
||||
(log.target_type && log.target_type.toLowerCase().includes(term)) ||
|
||||
(log.target_status && log.target_status.toLowerCase().includes(term)) ||
|
||||
(log.ip_address && log.ip_address.toLowerCase().includes(term))
|
||||
);
|
||||
});
|
||||
}, [logs, searchTerm, statusFilter, bannedWallets]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-indigo-400">
|
||||
<ScrollText size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Journal & Contrôle des Uploads</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Historique des transferts, diagnostic OpenCV (détection cibles) et modération des wallets.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-slate-900 border border-slate-800 hover:bg-slate-800 text-slate-300 hover:text-white rounded-xl text-sm font-semibold transition-all disabled:opacity-50"
|
||||
title="Rafraîchir les logs"
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? "animate-spin text-indigo-400" : ""} />
|
||||
<span>Actualiser</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={`${API_BASE_URL}/api/logs/export?format=csv`}
|
||||
download
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-sm font-semibold transition-all shadow-lg shadow-indigo-600/20 hover:scale-105 active:scale-95"
|
||||
>
|
||||
<Download size={16} />
|
||||
<span>Exporter CSV</span>
|
||||
</a>
|
||||
|
||||
{!showClearConfirm ? (
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
className="flex items-center gap-2 px-3 py-2.5 bg-slate-900 border border-slate-800 hover:border-rose-500/40 text-slate-400 hover:text-rose-400 rounded-xl text-sm transition-all"
|
||||
title="Purger tous les logs"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 p-1 bg-rose-500/10 border border-rose-500/30 rounded-xl animate-in fade-in">
|
||||
<span className="text-xs text-rose-400 px-2 font-semibold">Effacer tout ?</span>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="px-2.5 py-1 bg-rose-600 hover:bg-rose-500 text-white rounded-lg text-xs font-bold transition-all"
|
||||
>
|
||||
Oui
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
className="px-2.5 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs transition-all"
|
||||
>
|
||||
Non
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Total Uploads</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-2xl font-bold">{stats?.total_uploads ?? logs.length}</p>
|
||||
{stats && stats.total_uploads > 0 && (
|
||||
<span className="text-xs font-semibold text-emerald-400">
|
||||
{Math.round(((stats.success_count || 0) / stats.total_uploads) * 100)}% succès
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-11 h-11 bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 rounded-xl flex items-center justify-center">
|
||||
<ScrollText size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Aujourd'hui</p>
|
||||
<p className="text-2xl font-bold text-indigo-400">{stats?.today_uploads ?? 0}</p>
|
||||
</div>
|
||||
<div className="w-11 h-11 bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 rounded-xl flex items-center justify-center">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Wallets Bannis</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-2xl font-bold text-rose-400">{bannedWallets.length}</p>
|
||||
<span className="text-xs text-slate-500 font-mono">/ {stats?.unique_wallets ?? 0} total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-11 h-11 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-xl flex items-center justify-center">
|
||||
<Ban size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Volume Transféré</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{formatFileSize(stats?.total_bytes || logs.reduce((acc, l) => acc + (l.file_size || 0), 0))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-11 h-11 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-xl flex items-center justify-center">
|
||||
<HardDrive size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters & Search Toolbar */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-4 flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||
{/* Search */}
|
||||
<div className="relative w-full md:w-96">
|
||||
<Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Rechercher par Wallet, Session, Cible, Image..."
|
||||
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 rounded-xl pl-10 pr-4 py-2 text-sm text-slate-200 placeholder-slate-500 outline-none transition-all"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status & OpenCV Filter */}
|
||||
<div className="flex items-center gap-2 w-full md:w-auto justify-start md:justify-end flex-wrap">
|
||||
<span className="text-xs text-slate-500 font-semibold uppercase tracking-wider flex items-center gap-1.5 mr-1">
|
||||
<Filter size={13} />
|
||||
Filtre:
|
||||
</span>
|
||||
<div className="inline-flex bg-slate-950 p-1 rounded-xl border border-slate-800 flex-wrap gap-1">
|
||||
<button
|
||||
onClick={() => setStatusFilter("ALL")}
|
||||
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
|
||||
statusFilter === "ALL"
|
||||
? "bg-slate-800 text-white shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
Tous ({logs.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter("SUCCESS")}
|
||||
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
|
||||
statusFilter === "SUCCESS"
|
||||
? "bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"
|
||||
: "text-slate-400 hover:text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
Succès
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter("INVALID_TARGET")}
|
||||
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
|
||||
statusFilter === "INVALID_TARGET"
|
||||
? "bg-amber-500/20 text-amber-300 border border-amber-500/30"
|
||||
: "text-slate-400 hover:text-amber-400"
|
||||
}`}
|
||||
title="Photos non reconnues comme cible par OpenCV"
|
||||
>
|
||||
⚠ Cibles Douteuses
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter("BANNED")}
|
||||
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
|
||||
statusFilter === "BANNED"
|
||||
? "bg-rose-500/20 text-rose-300 border border-rose-500/30"
|
||||
: "text-slate-400 hover:text-rose-400"
|
||||
}`}
|
||||
>
|
||||
Bannis ({bannedWallets.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-xl shadow-black/20">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-slate-950/60 border-b border-slate-800 text-slate-400 font-semibold text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-5 py-3.5">Date & Heure</th>
|
||||
<th className="px-4 py-3.5">Diagnostic OpenCV</th>
|
||||
<th className="px-4 py-3.5">Wallet Contributeur</th>
|
||||
<th className="px-4 py-3.5">Session / Cible</th>
|
||||
<th className="px-4 py-3.5">Appareil</th>
|
||||
<th className="px-4 py-3.5">Fichier</th>
|
||||
<th className="px-4 py-3.5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{filteredLogs.map((log) => {
|
||||
const dateObj = new Date(log.timestamp);
|
||||
const isValidDate = !isNaN(dateObj.getTime());
|
||||
const formattedDate = isValidDate
|
||||
? dateObj.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" })
|
||||
: log.timestamp;
|
||||
const formattedTime = isValidDate
|
||||
? dateObj.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
||||
: "";
|
||||
|
||||
const banned = isWalletBanned(log.wallet_hash);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={log.id}
|
||||
className="hover:bg-slate-800/40 transition-colors group"
|
||||
>
|
||||
{/* Timestamp */}
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-slate-200">{formattedDate}</span>
|
||||
<span className="text-xs text-slate-500 font-mono">{formattedTime}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* OpenCV Diagnosis & Upload Status */}
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-1">
|
||||
{log.status === "FAILED" ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20 max-w-fit" title={log.error_message || "Échec upload"}>
|
||||
<XCircle size={12} />
|
||||
UPLOAD ÉCHEC
|
||||
</span>
|
||||
) : log.target_status === "VALID" ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 max-w-fit" title={log.target_details || "Cible certifiée"}>
|
||||
<ShieldCheck size={12} />
|
||||
CIBLE CERTIFIÉE ({Math.round((log.target_confidence || 1) * 100)}%)
|
||||
</span>
|
||||
) : log.target_status === "SUSPICIOUS" ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20 max-w-fit" title={log.target_details || "1 seul anneau détecté"}>
|
||||
<AlertTriangle size={12} />
|
||||
DOUTEUSE (1 ANNEAU)
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20 max-w-fit" title={log.target_details || "Aucun motif de cible"}>
|
||||
<ShieldAlert size={12} />
|
||||
NON RECONNUE
|
||||
</span>
|
||||
)}
|
||||
|
||||
{log.target_rings_count ? (
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{log.target_rings_count} anneaux concentriques
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Wallet Hash & Ban Badge */}
|
||||
<td className="px-4 py-4">
|
||||
{log.wallet_hash ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
onClick={() => setSearchTerm(log.wallet_hash || "")}
|
||||
className="font-mono text-xs text-indigo-300 hover:text-indigo-200 bg-indigo-500/5 px-2 py-1 rounded border border-indigo-500/10 hover:border-indigo-500/30 cursor-pointer transition-colors"
|
||||
title="Cliquer pour filtrer par ce wallet"
|
||||
>
|
||||
{log.wallet_hash.length > 16
|
||||
? `${log.wallet_hash.substring(0, 8)}...${log.wallet_hash.substring(log.wallet_hash.length - 8)}`
|
||||
: log.wallet_hash}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(log.wallet_hash!)}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors p-1"
|
||||
title="Copier le hash du wallet"
|
||||
>
|
||||
{copiedWallet === log.wallet_hash ? (
|
||||
<Check size={14} className="text-emerald-400" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{banned && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded border border-rose-500/20 max-w-fit">
|
||||
<Ban size={10} />
|
||||
WALLET BANNI
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 italic">Anonyme / Non fourni</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Session & Target */}
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex flex-col gap-1 max-w-[180px]">
|
||||
{log.session_id && (
|
||||
<div className="flex items-center gap-1 text-xs text-slate-300 font-mono truncate">
|
||||
<span className="text-slate-500">ID:</span> {log.session_id}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center flex-wrap gap-1.5">
|
||||
{log.target_type && (
|
||||
<span className="text-[10px] uppercase font-bold bg-slate-800 text-slate-300 px-1.5 py-0.5 rounded border border-slate-700">
|
||||
{log.target_type}
|
||||
</span>
|
||||
)}
|
||||
{log.weapon && (
|
||||
<span className="text-[10px] bg-slate-800 text-slate-400 px-1.5 py-0.5 rounded">
|
||||
{log.weapon}
|
||||
</span>
|
||||
)}
|
||||
{log.impacts_count > 0 && (
|
||||
<span className="text-[10px] font-bold text-indigo-400 bg-indigo-500/10 px-1.5 py-0.5 rounded border border-indigo-500/20 flex items-center gap-0.5">
|
||||
<Crosshair size={10} />
|
||||
{log.impacts_count} imp.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Device & IP */}
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||
<Smartphone size={13} className="text-indigo-400" />
|
||||
<span>{log.device_model || log.device_os || "Inconnu"}</span>
|
||||
</div>
|
||||
{log.ip_address && (
|
||||
<span className="text-[11px] text-slate-500 font-mono">
|
||||
{log.ip_address.replace("::ffff:", "")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* File & Size */}
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{log.image_filename ? (
|
||||
<Link
|
||||
href={`/photo/${log.image_filename}`}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 font-mono flex items-center gap-1 group/link truncate max-w-[130px]"
|
||||
title={log.image_filename}
|
||||
>
|
||||
<span className="truncate">{log.image_filename}</span>
|
||||
<ExternalLink size={12} className="opacity-0 group-hover/link:opacity-100 transition-opacity" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 italic">Aucun fichier</span>
|
||||
)}
|
||||
<span className="text-[11px] text-slate-500 font-mono">
|
||||
{formatFileSize(log.file_size)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-4 text-right whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{/* Ban / Unban Button */}
|
||||
{log.wallet_hash && (
|
||||
banned ? (
|
||||
<button
|
||||
onClick={() => handleUnbanWallet(log.wallet_hash!)}
|
||||
className="p-1.5 text-slate-400 hover:text-emerald-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
title="Débannir ce wallet"
|
||||
>
|
||||
<ShieldCheck size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setBanModalWallet(log.wallet_hash)}
|
||||
className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
title="Bannir définitivement ce wallet"
|
||||
>
|
||||
<Ban size={16} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="p-1.5 text-slate-400 hover:text-indigo-300 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
title="Voir les détails complets"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteLog(log.id)}
|
||||
className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
title="Supprimer ce log"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{filteredLogs.length === 0 && (
|
||||
<div className="py-20 flex flex-col items-center justify-center text-center px-4">
|
||||
<div className="p-4 bg-slate-950 border border-slate-800 rounded-2xl text-slate-600 mb-3">
|
||||
<ScrollText size={36} />
|
||||
</div>
|
||||
<h3 className="text-slate-300 font-bold text-base">Aucun log trouvé</h3>
|
||||
<p className="text-slate-500 text-xs mt-1 max-w-sm">
|
||||
{searchTerm || statusFilter !== "ALL"
|
||||
? "Aucun enregistrement ne correspond à vos critères de recherche."
|
||||
: "Les logs apparaîtront ici dès que des photos ou des sessions seront uploadées."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details Modal */}
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
|
||||
{/* Modal Header */}
|
||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-indigo-400">
|
||||
<Info size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-100">Détails du Log #{selectedLog.id}</h3>
|
||||
<p className="text-xs text-slate-400">{new Date(selectedLog.timestamp).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedLog(null)}
|
||||
className="text-slate-400 hover:text-white p-2 hover:bg-slate-800 rounded-xl transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="p-6 overflow-y-auto space-y-6 text-sm">
|
||||
{/* OpenCV Diagnostic Banner */}
|
||||
<div className={`p-4 rounded-xl border flex flex-col gap-2 ${
|
||||
selectedLog.target_status === "VALID"
|
||||
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-300"
|
||||
: selectedLog.target_status === "SUSPICIOUS"
|
||||
? "bg-amber-500/10 border-amber-500/20 text-amber-300"
|
||||
: "bg-rose-500/10 border-rose-500/20 text-rose-300"
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 font-bold">
|
||||
{selectedLog.target_status === "VALID" ? <ShieldCheck size={18} /> : <AlertTriangle size={18} />}
|
||||
Diagnostic OpenCV : {selectedLog.target_status || "VALID"}
|
||||
</div>
|
||||
<span className="text-xs font-mono font-bold px-2 py-0.5 rounded bg-black/30">
|
||||
Confiance : {Math.round((selectedLog.target_confidence || 1) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs opacity-90">{selectedLog.target_details || "Cible détectée avec succès."}</p>
|
||||
</div>
|
||||
|
||||
{/* General Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Wallet Hash</span>
|
||||
<div className="font-mono text-xs text-indigo-300 break-all select-all">
|
||||
{selectedLog.wallet_hash || "Non spécifié"}
|
||||
</div>
|
||||
{isWalletBanned(selectedLog.wallet_hash) && (
|
||||
<span className="inline-block mt-1 text-[10px] text-rose-400 font-bold bg-rose-500/10 px-1.5 py-0.5 rounded border border-rose-500/20">
|
||||
Banni ({getBannedInfo(selectedLog.wallet_hash)?.reason})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Session ID</span>
|
||||
<div className="font-mono text-xs text-slate-300 break-all select-all">
|
||||
{selectedLog.session_id || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Appareil</span>
|
||||
<div className="text-xs text-slate-300">
|
||||
{selectedLog.device_model || "Inconnu"} ({selectedLog.device_os || "OS inconnu"})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Adresse IP</span>
|
||||
<div className="font-mono text-xs text-slate-300">
|
||||
{selectedLog.ip_address || "Inconnue"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Cible & Arme</span>
|
||||
<div className="text-xs text-slate-300">
|
||||
Type: <span className="font-bold text-white">{selectedLog.target_type || "N/A"}</span> | Arme: {selectedLog.weapon || "N/A"} | {selectedLog.distance_meters ? `${selectedLog.distance_meters}m` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Fichiers & Taille</span>
|
||||
<div className="text-xs text-slate-300">
|
||||
Image: <span className="font-mono">{selectedLog.image_filename || "N/A"}</span> ({formatFileSize(selectedLog.file_size)})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Raw Metadata JSON */}
|
||||
{selectedLog.raw_metadata && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Données Brutes (JSON)</span>
|
||||
<pre className="bg-slate-950 border border-slate-800 p-4 rounded-xl text-xs font-mono text-slate-300 overflow-x-auto max-h-48">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(selectedLog.raw_metadata), null, 2);
|
||||
} catch {
|
||||
return selectedLog.raw_metadata;
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-4 border-t border-slate-800 bg-slate-950/50 flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedLog.image_filename && (
|
||||
<Link
|
||||
href={`/photo/${selectedLog.image_filename}`}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-bold transition-all"
|
||||
>
|
||||
<Eye size={14} />
|
||||
Ouvrir la session
|
||||
</Link>
|
||||
)}
|
||||
{selectedLog.wallet_hash && (
|
||||
isWalletBanned(selectedLog.wallet_hash) ? (
|
||||
<button
|
||||
onClick={() => handleUnbanWallet(selectedLog.wallet_hash!)}
|
||||
className="px-3 py-2 bg-slate-800 hover:bg-emerald-600/30 text-emerald-400 rounded-xl text-xs font-bold transition-all border border-emerald-500/20"
|
||||
>
|
||||
Débannir ce wallet
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedLog(null);
|
||||
setBanModalWallet(selectedLog.wallet_hash);
|
||||
}}
|
||||
className="px-3 py-2 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20"
|
||||
>
|
||||
Bannir ce wallet
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedLog(null)}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-all"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ban Confirmation Modal */}
|
||||
{banModalWallet && (
|
||||
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 space-y-6 shadow-2xl animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400">
|
||||
<Ban size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-100">Bannir un Wallet</h3>
|
||||
<p className="text-xs text-slate-400">Bloquer définitivement tout upload futur de ce compte.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-3 rounded-xl border border-slate-800 font-mono text-xs text-indigo-300 break-all">
|
||||
{banModalWallet}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
Motif du bannissement
|
||||
</label>
|
||||
<select
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 focus:border-rose-500 rounded-xl px-3 py-2 text-sm text-slate-200 outline-none"
|
||||
>
|
||||
<option value="Images non conformes / Fausse cible">Images non conformes / Fausse cible</option>
|
||||
<option value="Spam / Uploads abusifs répétés">Spam / Uploads abusifs répétés</option>
|
||||
<option value="Contenu inapproprié ou illicite">Contenu inapproprié ou illicite</option>
|
||||
<option value="Tentative de manipulation des données">Tentative de manipulation des données</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setBanModalWallet(null)}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-all"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBanWallet}
|
||||
disabled={isSubmittingBan}
|
||||
className="px-4 py-2 bg-rose-600 hover:bg-rose-500 text-white rounded-xl text-xs font-bold transition-all disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
<Ban size={14} />
|
||||
{isSubmittingBan ? "Bannissement..." : "Confirmer le Bannissement"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { fetchApi } from "@/lib/api";
|
||||
import LogsManager from "./LogsManager";
|
||||
|
||||
export const metadata = {
|
||||
title: "Logs d'Uploads | Bully IA Dashboard",
|
||||
description: "Journal complet des téléversements de sessions et métadonnées",
|
||||
};
|
||||
|
||||
export default async function LogsPage() {
|
||||
let logs = [];
|
||||
let stats = null;
|
||||
|
||||
try {
|
||||
const [logsRes, statsRes] = await Promise.all([
|
||||
fetchApi('/api/logs?limit=100'),
|
||||
fetchApi('/api/logs/stats')
|
||||
]);
|
||||
logs = logsRes.logs || [];
|
||||
stats = statsRes.stats || null;
|
||||
} catch (error) {
|
||||
console.error("Erreur chargement initial des logs:", error);
|
||||
}
|
||||
|
||||
return (
|
||||
<LogsManager
|
||||
initialLogs={logs}
|
||||
initialStats={stats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user