- 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.
901 lines
40 KiB
TypeScript
901 lines
40 KiB
TypeScript
"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>
|
|
);
|
|
}
|