Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e111f76731 | ||
|
|
5d7d5e6b54 | ||
|
|
bc77462c27 | ||
|
|
4437a1f436 | ||
|
|
1b2310b12b | ||
|
|
44ac4462a6 |
@@ -0,0 +1,24 @@
|
||||
name: Deploy Backendia
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Correspond au label 'docker:host' de votre runner
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: 📥 Récupération du code
|
||||
run: |
|
||||
echo "📥 Récupération du code..."
|
||||
git clone --depth 1 --branch ${{ github.ref_name }} "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@git.kevlar.cloud/${{ github.repository }}.git" .
|
||||
|
||||
- name: 🚀 Build et Déploiement Docker
|
||||
run: |
|
||||
echo "🚀 Démarrage du déploiement..."
|
||||
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
|
||||
echo "🧹 Nettoyage des anciennes images inutilisées..."
|
||||
docker image prune -f
|
||||
echo "✅ Déploiement terminé avec succès !"
|
||||
@@ -7,6 +7,15 @@
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
dashboard/node_modules
|
||||
dashboard/.next
|
||||
uploads
|
||||
exports
|
||||
.git
|
||||
.env*
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dépendances système pour les modules natifs (sharp, sqlite3, opencv)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
# Dossiers nécessaires
|
||||
RUN mkdir -p uploads/images uploads/data exports
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Generated
+577
-3
@@ -9,14 +9,526 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@techstark/opencv-js": "^5.0.0-release.1",
|
||||
"adm-zip": "^0.5.17",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"sharp": "^0.35.3",
|
||||
"sqlite3": "^6.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -29,6 +541,12 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@techstark/opencv-js": {
|
||||
"version": "5.0.0-release.1",
|
||||
"resolved": "https://registry.npmjs.org/@techstark/opencv-js/-/opencv-js-5.0.0-release.1.tgz",
|
||||
"integrity": "sha512-PIm+eB0MFtieXoNC2GRao0dv/02sehG+Nv2nSW5D6pQm6J/4WqvDHm0RyoqOmGYQm67jdGiaOdIeTShCY3PIUg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
|
||||
@@ -1264,9 +1782,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1326,6 +1844,55 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
@@ -1581,6 +2148,13 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@techstark/opencv-js": "^5.0.0-release.1",
|
||||
"adm-zip": "^0.5.17",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"sharp": "^0.35.3",
|
||||
"sqlite3": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
+493
-12
@@ -7,6 +7,8 @@ const sqlite3 = require('sqlite3').verbose();
|
||||
const AdmZip = require('adm-zip');
|
||||
|
||||
|
||||
const TargetValidator = require('./services/target_validator');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
@@ -39,9 +41,119 @@ const db = new sqlite3.Database(dbPath, (err) => {
|
||||
photo_count INTEGER DEFAULT 0,
|
||||
last_upload DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS banned_wallets (
|
||||
wallet_hash TEXT PRIMARY KEY,
|
||||
reason TEXT,
|
||||
banned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
banned_by TEXT DEFAULT 'Admin'
|
||||
)`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS upload_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
session_id TEXT,
|
||||
wallet_hash TEXT,
|
||||
image_filename TEXT,
|
||||
json_filename TEXT,
|
||||
file_size INTEGER,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
device_model TEXT,
|
||||
device_os TEXT,
|
||||
target_type TEXT,
|
||||
weapon TEXT,
|
||||
distance_meters INTEGER,
|
||||
impacts_count INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'SUCCESS',
|
||||
target_valid INTEGER DEFAULT 1,
|
||||
target_status TEXT DEFAULT 'VALID',
|
||||
target_confidence REAL DEFAULT 1.0,
|
||||
target_rings_count INTEGER DEFAULT 0,
|
||||
target_details TEXT,
|
||||
error_message TEXT,
|
||||
raw_metadata TEXT
|
||||
)`);
|
||||
|
||||
// Migration safe des colonnes OpenCV si la table existait déjà
|
||||
const migrations = [
|
||||
"ALTER TABLE upload_logs ADD COLUMN target_valid INTEGER DEFAULT 1",
|
||||
"ALTER TABLE upload_logs ADD COLUMN target_status TEXT DEFAULT 'VALID'",
|
||||
"ALTER TABLE upload_logs ADD COLUMN target_confidence REAL DEFAULT 1.0",
|
||||
"ALTER TABLE upload_logs ADD COLUMN target_rings_count INTEGER DEFAULT 0",
|
||||
"ALTER TABLE upload_logs ADD COLUMN target_details TEXT"
|
||||
];
|
||||
migrations.forEach(sql => db.run(sql, () => {}));
|
||||
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_wallet ON upload_logs(wallet_hash)`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_timestamp ON upload_logs(timestamp)`);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper pour insérer un log d'upload
|
||||
function logUploadEntry({
|
||||
sessionId = null,
|
||||
walletHash = null,
|
||||
imageFilename = null,
|
||||
jsonFilename = null,
|
||||
fileSize = null,
|
||||
ipAddress = null,
|
||||
userAgent = null,
|
||||
deviceModel = null,
|
||||
deviceOs = null,
|
||||
targetType = null,
|
||||
weapon = null,
|
||||
distanceMeters = null,
|
||||
impactsCount = 0,
|
||||
status = 'SUCCESS',
|
||||
targetValid = 1,
|
||||
targetStatus = 'VALID',
|
||||
targetConfidence = 1.0,
|
||||
targetRingsCount = 0,
|
||||
targetDetails = null,
|
||||
errorMessage = null,
|
||||
rawMetadata = null
|
||||
}) {
|
||||
const query = `
|
||||
INSERT INTO upload_logs (
|
||||
session_id, wallet_hash, image_filename, json_filename, file_size,
|
||||
ip_address, user_agent, device_model, device_os, target_type,
|
||||
weapon, distance_meters, impacts_count, status,
|
||||
target_valid, target_status, target_confidence, target_rings_count, target_details,
|
||||
error_message, raw_metadata
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
db.run(query, [
|
||||
sessionId,
|
||||
walletHash,
|
||||
imageFilename,
|
||||
jsonFilename,
|
||||
fileSize,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
deviceModel,
|
||||
deviceOs,
|
||||
targetType,
|
||||
weapon,
|
||||
distanceMeters,
|
||||
impactsCount,
|
||||
status,
|
||||
targetValid ? 1 : 0,
|
||||
targetStatus,
|
||||
targetConfidence,
|
||||
targetRingsCount,
|
||||
targetDetails,
|
||||
errorMessage,
|
||||
rawMetadata ? JSON.stringify(rawMetadata) : null
|
||||
], function(err) {
|
||||
if (err) {
|
||||
console.error("Erreur lors de l'insertion dans upload_logs:", err.message);
|
||||
} else {
|
||||
console.log(`[LOG] Upload enregistré (ID: ${this.lastID}) - Statut: ${status} - OpenCV: ${targetStatus}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Configuration de multer pour le stockage des fichiers
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
@@ -81,10 +193,22 @@ app.get('/api/health', (req, res) => {
|
||||
|
||||
// Route pour l'upload de photo + données JSON
|
||||
// Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting'
|
||||
app.post('/api/upload', upload.single('photo'), (req, res) => {
|
||||
app.post('/api/upload', upload.single('photo'), async (req, res) => {
|
||||
const ipAddress = req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip || '';
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Aucune photo fournie' });
|
||||
logUploadEntry({
|
||||
ipAddress,
|
||||
userAgent,
|
||||
status: 'FAILED',
|
||||
errorMessage: 'Aucune photo fournie'
|
||||
});
|
||||
return res.status(400).json({
|
||||
code: 'MISSING_PHOTO',
|
||||
error: 'Aucune photo fournie dans la requête'
|
||||
});
|
||||
}
|
||||
|
||||
let plottingData = {};
|
||||
@@ -93,10 +217,75 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
|
||||
plottingData = JSON.parse(req.body.plotting);
|
||||
} catch (e) {
|
||||
console.error("Erreur parsing JSON:", e);
|
||||
return res.status(400).json({ error: 'Le champ plotting doit être un JSON valide' });
|
||||
logUploadEntry({
|
||||
imageFilename: req.file.filename,
|
||||
fileSize: req.file.size,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
status: 'FAILED',
|
||||
errorMessage: 'Le champ plotting doit être un JSON valide'
|
||||
});
|
||||
return res.status(400).json({
|
||||
code: 'INVALID_PLOTTING_JSON',
|
||||
error: 'Le champ plotting contient un format JSON invalide'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const walletHash = plottingData.wallet_hash || null;
|
||||
const sessionId = plottingData.session_id || null;
|
||||
|
||||
// 1. Vérification du bannissement de wallet
|
||||
if (walletHash) {
|
||||
const bannedEntry = await new Promise((resolve) => {
|
||||
db.get('SELECT * FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, row) => {
|
||||
resolve(row || null);
|
||||
});
|
||||
});
|
||||
|
||||
if (bannedEntry) {
|
||||
// Supprimer le fichier image temporaire pour ne pas consommer d'espace
|
||||
if (req.file.path && fs.existsSync(req.file.path)) {
|
||||
try { fs.unlinkSync(req.file.path); } catch (e) {}
|
||||
}
|
||||
const banReason = bannedEntry.reason || 'Non-respect des règles de contribution / Image non conforme';
|
||||
logUploadEntry({
|
||||
sessionId,
|
||||
walletHash,
|
||||
imageFilename: req.file.filename,
|
||||
fileSize: req.file.size,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
status: 'FAILED',
|
||||
errorMessage: `Upload bloqué : wallet banni (${banReason})`
|
||||
});
|
||||
console.warn(`[MODÉRATION] Upload rejeté pour wallet banni: ${walletHash}`);
|
||||
return res.status(403).json({
|
||||
code: 'WALLET_BANNED',
|
||||
error: 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
|
||||
reason: banReason,
|
||||
banned_at: bannedEntry.banned_at
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Validation de la cible par OpenCV
|
||||
let validation = {
|
||||
isValid: true,
|
||||
status: 'VALID',
|
||||
confidence: 1.0,
|
||||
ringsCount: 0,
|
||||
bestCenter: null,
|
||||
details: 'Analyse non effectuée'
|
||||
};
|
||||
|
||||
try {
|
||||
validation = await TargetValidator.validateTarget(req.file.path);
|
||||
console.log(`[OPENCV] Diagnostic cible pour ${req.file.filename}: ${validation.status} (${Math.round(validation.confidence * 100)}% conf, ${validation.ringsCount} anneaux)`);
|
||||
} catch (cvErr) {
|
||||
console.error("[OPENCV] Erreur analyse cible:", cvErr);
|
||||
}
|
||||
|
||||
// Nom de base sans l'extension
|
||||
const baseFilename = path.parse(req.file.filename).name;
|
||||
const jsonFilename = `${baseFilename}.json`;
|
||||
@@ -105,8 +294,13 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
|
||||
// Sauvegarde du JSON dans uploads/data/
|
||||
fs.writeFileSync(jsonFilePath, JSON.stringify(plottingData, null, 2));
|
||||
|
||||
// Extraction des métadonnées
|
||||
const deviceInfo = plottingData.device_info || {};
|
||||
const targetMeta = plottingData.target_metadata || {};
|
||||
const impacts = plottingData.plotting?.impacts || [];
|
||||
const impactsCount = Array.isArray(impacts) ? impacts.length : 0;
|
||||
|
||||
// Mise à jour de la BDD si on a un wallet_hash
|
||||
const walletHash = plottingData.wallet_hash;
|
||||
if (walletHash) {
|
||||
db.run(`
|
||||
INSERT INTO user_stats (wallet_hash, photo_count, last_upload)
|
||||
@@ -123,11 +317,36 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Enregistrement dans la table des logs d'upload avec diagnostic OpenCV
|
||||
logUploadEntry({
|
||||
sessionId,
|
||||
walletHash,
|
||||
imageFilename: req.file.filename,
|
||||
jsonFilename,
|
||||
fileSize: req.file.size,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
deviceModel: deviceInfo.model || null,
|
||||
deviceOs: deviceInfo.os || null,
|
||||
targetType: targetMeta.type || null,
|
||||
weapon: targetMeta.weapon || null,
|
||||
distanceMeters: targetMeta.distance_meters || null,
|
||||
impactsCount,
|
||||
status: 'SUCCESS',
|
||||
targetValid: validation.isValid ? 1 : 0,
|
||||
targetStatus: validation.status,
|
||||
targetConfidence: validation.confidence,
|
||||
targetRingsCount: validation.ringsCount,
|
||||
targetDetails: validation.details,
|
||||
rawMetadata: plottingData
|
||||
});
|
||||
|
||||
console.log(`Données reçues et sauvegardées:`);
|
||||
console.log(`- Image: uploads/images/${req.file.filename}`);
|
||||
console.log(`- JSON : uploads/data/${jsonFilename}`);
|
||||
|
||||
res.status(200).json({
|
||||
code: 'UPLOAD_SUCCESS',
|
||||
message: 'Photo et données uploadées avec succès',
|
||||
file: {
|
||||
filename: req.file.filename,
|
||||
@@ -136,14 +355,254 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
|
||||
},
|
||||
data: {
|
||||
filename: jsonFilename
|
||||
}
|
||||
},
|
||||
target_validation: validation
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'upload:', error);
|
||||
res.status(500).json({ error: 'Erreur interne du serveur lors de l\'upload' });
|
||||
logUploadEntry({
|
||||
imageFilename: req.file ? req.file.filename : null,
|
||||
fileSize: req.file ? req.file.size : null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
status: 'FAILED',
|
||||
errorMessage: error.message || 'Erreur interne lors de l\'upload'
|
||||
});
|
||||
res.status(500).json({
|
||||
code: 'SERVER_ERROR',
|
||||
error: error.message || 'Erreur interne du serveur lors de l\'upload'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Routes de Modération : Gestion des bannissements de wallets
|
||||
app.get('/api/moderation/banned', (req, res) => {
|
||||
db.all('SELECT * FROM banned_wallets ORDER BY banned_at DESC', [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error("Erreur lecture wallets bannis:", err);
|
||||
return res.status(500).json({ error: 'Erreur lecture wallets bannis' });
|
||||
}
|
||||
res.json({ banned: rows || [] });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/moderation/ban', (req, res) => {
|
||||
const { wallet_hash, reason, banned_by } = req.body;
|
||||
if (!wallet_hash) {
|
||||
return res.status(400).json({ error: 'wallet_hash obligatoire' });
|
||||
}
|
||||
|
||||
db.run(
|
||||
`INSERT INTO banned_wallets (wallet_hash, reason, banned_by, banned_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(wallet_hash) DO UPDATE SET
|
||||
reason = excluded.reason,
|
||||
banned_at = CURRENT_TIMESTAMP`,
|
||||
[wallet_hash, reason || 'Contenu invalide ou non conforme aux règles', banned_by || 'Admin'],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error("Erreur bannissement:", err);
|
||||
return res.status(500).json({ error: 'Erreur lors du bannissement du wallet' });
|
||||
}
|
||||
console.log(`[MODÉRATION] Wallet ${wallet_hash} banni (Motif: ${reason})`);
|
||||
res.json({ message: 'Wallet banni avec succès', wallet_hash });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
app.delete('/api/moderation/ban/:wallet_hash', (req, res) => {
|
||||
const wallet_hash = req.params.wallet_hash;
|
||||
db.run('DELETE FROM banned_wallets WHERE wallet_hash = ?', [wallet_hash], function(err) {
|
||||
if (err) {
|
||||
console.error("Erreur débannissement:", err);
|
||||
return res.status(500).json({ error: 'Erreur lors du débannissement' });
|
||||
}
|
||||
console.log(`[MODÉRATION] Wallet ${wallet_hash} débanni`);
|
||||
res.json({ message: 'Wallet débanni avec succès', wallet_hash });
|
||||
});
|
||||
});
|
||||
|
||||
// Route pour relancer l'analyse OpenCV sur une photo déjà existante
|
||||
app.post('/api/moderation/verify/:filename', async (req, res) => {
|
||||
const filename = req.params.filename;
|
||||
const imgPath = path.join(imagesDir, filename);
|
||||
if (!fs.existsSync(imgPath)) {
|
||||
return res.status(404).json({ error: 'Image non trouvée' });
|
||||
}
|
||||
try {
|
||||
const result = await TargetValidator.validateTarget(imgPath);
|
||||
res.json({ filename, validation: result });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Route pour lister les logs avec filtres, recherche et pagination
|
||||
app.get('/api/logs', (req, res) => {
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
|
||||
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
||||
const wallet = req.query.wallet || req.query.wallet_hash;
|
||||
const status = req.query.status;
|
||||
const search = req.query.search;
|
||||
|
||||
let whereClauses = [];
|
||||
let params = [];
|
||||
|
||||
if (wallet) {
|
||||
whereClauses.push("wallet_hash LIKE ?");
|
||||
params.push(`%${wallet}%`);
|
||||
}
|
||||
if (status) {
|
||||
whereClauses.push("status = ?");
|
||||
params.push(status);
|
||||
}
|
||||
if (search) {
|
||||
whereClauses.push("(session_id LIKE ? OR wallet_hash LIKE ? OR device_model LIKE ? OR weapon LIKE ? OR target_type LIKE ? OR image_filename LIKE ?)");
|
||||
const s = `%${search}%`;
|
||||
params.push(s, s, s, s, s, s);
|
||||
}
|
||||
|
||||
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
||||
|
||||
db.get(`SELECT COUNT(*) as total FROM upload_logs ${whereSql}`, params, (err, countRow) => {
|
||||
if (err) {
|
||||
console.error("Erreur comptage logs:", err);
|
||||
return res.status(500).json({ error: 'Erreur lors du comptage des logs' });
|
||||
}
|
||||
|
||||
const total = countRow ? countRow.total : 0;
|
||||
const query = `
|
||||
SELECT * FROM upload_logs
|
||||
${whereSql}
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
db.all(query, [...params, limit, offset], (err, rows) => {
|
||||
if (err) {
|
||||
console.error("Erreur lecture logs:", err);
|
||||
return res.status(500).json({ error: 'Erreur lors de la lecture des logs' });
|
||||
}
|
||||
res.json({
|
||||
logs: rows,
|
||||
total,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Route pour les statistiques des logs
|
||||
app.get('/api/logs/stats', (req, res) => {
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
COUNT(*) as total_uploads,
|
||||
SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
|
||||
SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) as failed_count,
|
||||
SUM(CASE WHEN date(timestamp, 'localtime') = date('now', 'localtime') THEN 1 ELSE 0 END) as today_uploads,
|
||||
COUNT(DISTINCT wallet_hash) as unique_wallets,
|
||||
SUM(COALESCE(file_size, 0)) as total_bytes,
|
||||
MAX(timestamp) as latest_upload
|
||||
FROM upload_logs
|
||||
`;
|
||||
|
||||
db.get(statsQuery, [], (err, stats) => {
|
||||
if (err) {
|
||||
console.error("Erreur stats logs:", err);
|
||||
return res.status(500).json({ error: 'Erreur calcul statistiques' });
|
||||
}
|
||||
res.json({
|
||||
status: 'ok',
|
||||
stats: stats || {
|
||||
total_uploads: 0,
|
||||
success_count: 0,
|
||||
failed_count: 0,
|
||||
today_uploads: 0,
|
||||
unique_wallets: 0,
|
||||
total_bytes: 0,
|
||||
latest_upload: null
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Route pour exporter les logs (CSV ou JSON)
|
||||
app.get('/api/logs/export', (req, res) => {
|
||||
const format = req.query.format || 'csv';
|
||||
|
||||
db.all('SELECT * FROM upload_logs ORDER BY timestamp DESC, id DESC', [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error("Erreur export logs:", err);
|
||||
return res.status(500).json({ error: 'Erreur export logs' });
|
||||
}
|
||||
|
||||
if (format === 'json') {
|
||||
res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.json`);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.send(JSON.stringify(rows, null, 2));
|
||||
}
|
||||
|
||||
// CSV Export
|
||||
const headers = [
|
||||
'ID', 'Date/Heure', 'Statut', 'Wallet Hash', 'Session ID',
|
||||
'Image', 'JSON', 'Taille (octets)', 'Cible', 'Arme', 'Distance (m)',
|
||||
'Impacts', 'Modèle Appareil', 'OS', 'IP', 'Erreur'
|
||||
];
|
||||
|
||||
const csvLines = [headers.join(';')];
|
||||
|
||||
rows.forEach(r => {
|
||||
const line = [
|
||||
r.id,
|
||||
`"${r.timestamp || ''}"`,
|
||||
`"${r.status || ''}"`,
|
||||
`"${r.wallet_hash || ''}"`,
|
||||
`"${r.session_id || ''}"`,
|
||||
`"${r.image_filename || ''}"`,
|
||||
`"${r.json_filename || ''}"`,
|
||||
r.file_size || 0,
|
||||
`"${r.target_type || ''}"`,
|
||||
`"${r.weapon || ''}"`,
|
||||
r.distance_meters || '',
|
||||
r.impacts_count || 0,
|
||||
`"${r.device_model || ''}"`,
|
||||
`"${r.device_os || ''}"`,
|
||||
`"${r.ip_address || ''}"`,
|
||||
`"${(r.error_message || '').replace(/"/g, '""')}"`
|
||||
];
|
||||
csvLines.push(line.join(';'));
|
||||
});
|
||||
|
||||
res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.csv`);
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.send('\uFEFF' + csvLines.join('\r\n'));
|
||||
});
|
||||
});
|
||||
|
||||
// Route pour supprimer un log spécifique
|
||||
app.delete('/api/logs/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
db.run('DELETE FROM upload_logs WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
console.error("Erreur suppression log:", err);
|
||||
return res.status(500).json({ error: 'Erreur lors de la suppression' });
|
||||
}
|
||||
res.json({ message: 'Log supprimé avec succès', deletedId: id });
|
||||
});
|
||||
});
|
||||
|
||||
// Route pour vider les logs
|
||||
app.delete('/api/logs', (req, res) => {
|
||||
db.run('DELETE FROM upload_logs', [], function(err) {
|
||||
if (err) {
|
||||
console.error("Erreur vidage logs:", err);
|
||||
return res.status(500).json({ error: 'Erreur lors de la réinitialisation des logs' });
|
||||
}
|
||||
res.json({ message: 'Tous les logs ont été effacés', changes: this.changes });
|
||||
});
|
||||
});
|
||||
|
||||
// Route pour récupérer toutes les photos disponibles
|
||||
app.get('/api/photos', (req, res) => {
|
||||
try {
|
||||
@@ -164,20 +623,42 @@ app.get('/api/photos', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route pour récupérer les statistiques d'un wallet_hash
|
||||
// Route pour récupérer les statistiques d'un wallet_hash + statut de modération
|
||||
app.get('/api/stats/:wallet_hash', (req, res) => {
|
||||
const walletHash = req.params.wallet_hash;
|
||||
|
||||
db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, row) => {
|
||||
db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, statRow) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' });
|
||||
}
|
||||
|
||||
if (row) {
|
||||
res.json({ status: 'ok', stats: row });
|
||||
} else {
|
||||
res.json({ status: 'ok', stats: { photo_count: 0, last_upload: null } });
|
||||
db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (bErr, bannedRow) => {
|
||||
const isBanned = !!bannedRow;
|
||||
const stats = statRow || { photo_count: 0, last_upload: null };
|
||||
res.json({
|
||||
status: 'ok',
|
||||
stats,
|
||||
is_banned: isBanned,
|
||||
ban_reason: bannedRow ? bannedRow.reason : null,
|
||||
banned_at: bannedRow ? bannedRow.banned_at : null
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Route directe pour vérifier l'état de modération d'un wallet
|
||||
app.get('/api/moderation/status/:wallet_hash', (req, res) => {
|
||||
const walletHash = req.params.wallet_hash;
|
||||
db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, bannedRow) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Erreur lecture statut modération' });
|
||||
}
|
||||
res.json({
|
||||
wallet_hash: walletHash,
|
||||
is_banned: !!bannedRow,
|
||||
ban_reason: bannedRow ? bannedRow.reason : null,
|
||||
banned_at: bannedRow ? bannedRow.banned_at : null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
const cvPromise = require('@techstark/opencv-js');
|
||||
const sharp = require('sharp');
|
||||
|
||||
let cvInstance = null;
|
||||
|
||||
async function getCV() {
|
||||
if (!cvInstance) {
|
||||
cvInstance = await cvPromise;
|
||||
}
|
||||
return cvInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service de validation OpenCV pour vérifier si une image reçue est bien une cible de tir.
|
||||
*/
|
||||
class TargetValidator {
|
||||
/**
|
||||
* Analyse une image et renvoie le diagnostic OpenCV
|
||||
* @param {string|Buffer} imageInput - Chemin du fichier ou Buffer de l'image
|
||||
* @returns {Promise<{
|
||||
* isValid: boolean,
|
||||
* status: 'VALID' | 'SUSPICIOUS' | 'INVALID',
|
||||
* confidence: number,
|
||||
* ringsCount: number,
|
||||
* bestCenter: { x: number, y: number, radius: number } | null,
|
||||
* details: string
|
||||
* }>}
|
||||
*/
|
||||
static async validateTarget(imageInput) {
|
||||
const cv = await getCV();
|
||||
|
||||
let src = null;
|
||||
let gray = null;
|
||||
let blurred = null;
|
||||
let circles = null;
|
||||
|
||||
try {
|
||||
// 1. Charger et décoder l'image avec Sharp pour obtenir les pixels bruts RGBA
|
||||
const { data, info } = await sharp(imageInput)
|
||||
.ensureAlpha() // 4 canaux RGBA
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
const width = info.width;
|
||||
const height = info.height;
|
||||
const minDim = Math.min(width, height);
|
||||
|
||||
// 2. Créer une Mat OpenCV à partir des pixels RGBA
|
||||
src = new cv.Mat(height, width, cv.CV_8UC4);
|
||||
src.data.set(data);
|
||||
|
||||
// 3. Conversion en niveaux de gris
|
||||
gray = new cv.Mat();
|
||||
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
|
||||
|
||||
// 4. Flou Gaussien pour réduire le bruit
|
||||
blurred = new cv.Mat();
|
||||
const ksize = new cv.Size(9, 9);
|
||||
cv.GaussianBlur(gray, blurred, ksize, 2, 2, cv.BORDER_DEFAULT);
|
||||
|
||||
// 5. Détection multi-bandes pour capturer tous les anneaux concentriques
|
||||
// (Puisque minDist dans HoughCircles empêche la détection de cercles concentriques sur un seul passage)
|
||||
const detectedCircles = [];
|
||||
const bands = [
|
||||
{ min: Math.floor(minDim * 0.04), max: Math.floor(minDim * 0.18), p2: 45 },
|
||||
{ min: Math.floor(minDim * 0.18), max: Math.floor(minDim * 0.35), p2: 48 },
|
||||
{ min: Math.floor(minDim * 0.35), max: Math.floor(minDim * 0.55), p2: 50 }
|
||||
];
|
||||
|
||||
circles = new cv.Mat();
|
||||
|
||||
for (const band of bands) {
|
||||
cv.HoughCircles(
|
||||
blurred,
|
||||
circles,
|
||||
cv.HOUGH_GRADIENT,
|
||||
1,
|
||||
Math.max(Math.floor(minDim * 0.05), 10),
|
||||
100,
|
||||
band.p2,
|
||||
band.min,
|
||||
band.max
|
||||
);
|
||||
|
||||
if (circles.cols > 0 && circles.data32F) {
|
||||
for (let i = 0; i < circles.cols; i++) {
|
||||
const x = circles.data32F[i * 3];
|
||||
const y = circles.data32F[i * 3 + 1];
|
||||
const r = circles.data32F[i * 3 + 2];
|
||||
if (!isNaN(x) && !isNaN(y) && !isNaN(r) && r > 0) {
|
||||
detectedCircles.push({ x, y, r });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si aucun cercle dans les bandes, tentative globale tolérante
|
||||
if (detectedCircles.length === 0) {
|
||||
cv.HoughCircles(
|
||||
blurred,
|
||||
circles,
|
||||
cv.HOUGH_GRADIENT,
|
||||
1,
|
||||
Math.max(Math.floor(minDim * 0.08), 10),
|
||||
100,
|
||||
35,
|
||||
Math.floor(minDim * 0.05),
|
||||
Math.floor(minDim * 0.55)
|
||||
);
|
||||
|
||||
if (circles.cols > 0 && circles.data32F) {
|
||||
for (let i = 0; i < circles.cols; i++) {
|
||||
const x = circles.data32F[i * 3];
|
||||
const y = circles.data32F[i * 3 + 1];
|
||||
const r = circles.data32F[i * 3 + 2];
|
||||
if (!isNaN(x) && !isNaN(y) && !isNaN(r) && r > 0) {
|
||||
detectedCircles.push({ x, y, r });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si aucun cercle détecté
|
||||
if (detectedCircles.length === 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
status: 'INVALID',
|
||||
confidence: 0.02,
|
||||
ringsCount: 0,
|
||||
bestCenter: null,
|
||||
details: 'Aucun motif circulaire ou cible détecté sur cette photo.'
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Clustering des cercles pour trouver les anneaux concentriques
|
||||
const tolerance = minDim * 0.07; // Tolérance de 7% pour le décalage de centre
|
||||
const clusters = [];
|
||||
|
||||
for (const circle of detectedCircles) {
|
||||
let added = false;
|
||||
for (const cluster of clusters) {
|
||||
// Prendre le centre moyen actuel du cluster
|
||||
const avgClusterX = cluster.reduce((sum, c) => sum + c.x, 0) / cluster.length;
|
||||
const avgClusterY = cluster.reduce((sum, c) => sum + c.y, 0) / cluster.length;
|
||||
const dist = Math.sqrt(Math.pow(circle.x - avgClusterX, 2) + Math.pow(circle.y - avgClusterY, 2));
|
||||
|
||||
if (dist < tolerance) {
|
||||
// Vérifier que le rayon n'est pas un doublon exact (< 10px de différence)
|
||||
const isDuplicate = cluster.some(c => Math.abs(c.r - circle.r) < minDim * 0.03);
|
||||
if (!isDuplicate) {
|
||||
cluster.push(circle);
|
||||
}
|
||||
added = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!added) {
|
||||
clusters.push([circle]);
|
||||
}
|
||||
}
|
||||
|
||||
// Trier les clusters par nombre de cercles concentriques décroissant
|
||||
clusters.sort((a, b) => b.length - a.length);
|
||||
const bestCluster = clusters[0];
|
||||
const ringsCount = bestCluster.length;
|
||||
|
||||
// Calculer le centre moyen du meilleur cluster
|
||||
const avgX = bestCluster.reduce((acc, c) => acc + c.x, 0) / ringsCount;
|
||||
const avgY = bestCluster.reduce((acc, c) => acc + c.y, 0) / ringsCount;
|
||||
const maxR = Math.max(...bestCluster.map(c => c.r));
|
||||
|
||||
// 7. Évaluation du statut et de la confiance
|
||||
if (ringsCount >= 3) {
|
||||
return {
|
||||
isValid: true,
|
||||
status: 'VALID',
|
||||
confidence: Math.min(0.92 + (ringsCount - 3) * 0.02, 0.99),
|
||||
ringsCount,
|
||||
bestCenter: { x: avgX, y: avgY, radius: maxR },
|
||||
details: `Cible certifiée : ${ringsCount} anneaux concentriques identifiés.`
|
||||
};
|
||||
} else if (ringsCount === 2) {
|
||||
return {
|
||||
isValid: true,
|
||||
status: 'VALID',
|
||||
confidence: 0.85,
|
||||
ringsCount,
|
||||
bestCenter: { x: avgX, y: avgY, radius: maxR },
|
||||
details: `Cible confirmée : ${ringsCount} anneaux concentriques détectés.`
|
||||
};
|
||||
} else if (ringsCount === 1) {
|
||||
return {
|
||||
isValid: true,
|
||||
status: 'SUSPICIOUS',
|
||||
confidence: 0.50,
|
||||
ringsCount: 1,
|
||||
bestCenter: { x: avgX, y: avgY, radius: maxR },
|
||||
details: 'Un seul anneau détecté, confirmation visuelle recommandée.'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
isValid: false,
|
||||
status: 'INVALID',
|
||||
confidence: 0.1,
|
||||
ringsCount: 0,
|
||||
bestCenter: null,
|
||||
details: 'Motif de cible non conforme.'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur TargetValidator OpenCV:', error);
|
||||
return {
|
||||
isValid: false,
|
||||
status: 'INVALID',
|
||||
confidence: 0.0,
|
||||
ringsCount: 0,
|
||||
bestCenter: null,
|
||||
details: `Erreur d'analyse OpenCV: ${error.message}`
|
||||
};
|
||||
} finally {
|
||||
// 8. Nettoyage mémoire
|
||||
if (src && !src.isDeleted()) src.delete();
|
||||
if (gray && !gray.isDeleted()) gray.delete();
|
||||
if (blurred && !blurred.isDeleted()) blurred.delete();
|
||||
if (circles && !circles.isDeleted()) circles.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TargetValidator;
|
||||
@@ -0,0 +1,21 @@
|
||||
name: backendia
|
||||
|
||||
services:
|
||||
backendia:
|
||||
build:
|
||||
context: ./backendia
|
||||
dockerfile: Dockerfile
|
||||
container_name: backendia-prod
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3005:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
volumes:
|
||||
- backendia_uploads:/app/uploads
|
||||
- backendia_exports:/app/exports
|
||||
|
||||
volumes:
|
||||
backendia_uploads:
|
||||
backendia_exports:
|
||||
@@ -241,15 +241,18 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
/// Exporte l'image et le json vers le backend IA.
|
||||
/// [sessionId], [distance] et [weapon] proviennent du SessionProvider de
|
||||
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
|
||||
Future<bool> exportToAiBackend({
|
||||
Future<AiExportResult> exportToAiBackend({
|
||||
String? sessionId,
|
||||
int? distance,
|
||||
String? weapon,
|
||||
}) async {
|
||||
if (_imagePath == null || _targetType == null) {
|
||||
_errorMessage = "Impossible d'export : image ou type de cible manquant.";
|
||||
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
|
||||
notifyListeners();
|
||||
return false;
|
||||
return AiExportResult.error(
|
||||
code: 'MISSING_DATA',
|
||||
message: "Impossible d'exporter : image ou type de cible manquant.",
|
||||
);
|
||||
}
|
||||
|
||||
final service = AiExportService();
|
||||
@@ -257,7 +260,7 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
_state = AnalysisState.loading;
|
||||
notifyListeners();
|
||||
|
||||
final success = await service.exportData(
|
||||
final result = await service.exportData(
|
||||
imagePath: _imagePath!,
|
||||
sessionId: sessionId ?? 'export',
|
||||
targetType: _targetType!,
|
||||
@@ -270,11 +273,11 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
);
|
||||
|
||||
_state = AnalysisState.success;
|
||||
if (!success) {
|
||||
_errorMessage = "Échec de l'export vers le serveur IA.";
|
||||
if (!result.isSuccess) {
|
||||
_errorMessage = result.message;
|
||||
}
|
||||
notifyListeners();
|
||||
return success;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Save the session
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../../data/repositories/session_repository.dart';
|
||||
import '../../services/score_calculator_service.dart';
|
||||
import '../../services/grouping_analyzer_service.dart';
|
||||
import '../../services/wallet_identity_service.dart';
|
||||
import '../../services/ai_export_service.dart';
|
||||
import '../session/session_provider.dart';
|
||||
import 'analysis_provider.dart';
|
||||
import 'impact_editor_screen.dart';
|
||||
@@ -933,12 +934,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
date: sessionProvider.sessionDate,
|
||||
);
|
||||
|
||||
bool? exportSucceeded;
|
||||
AiExportResult? exportResult;
|
||||
if (export) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Exportation en cours...')),
|
||||
const SnackBar(content: Text('Exportation vers le serveur IA en cours...')),
|
||||
);
|
||||
exportSucceeded = await provider.exportToAiBackend(
|
||||
exportResult = await provider.exportToAiBackend(
|
||||
sessionId: sessionProvider.activeSessionId,
|
||||
distance: sessionProvider.distance,
|
||||
weapon: sessionProvider.currentWeapon,
|
||||
@@ -954,19 +955,68 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
openMainTab(mainTabStats);
|
||||
}
|
||||
|
||||
if (exportSucceeded != null) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
exportSucceeded
|
||||
? 'Export réussi vers le backend IA !'
|
||||
: (provider.errorMessage ?? 'Erreur d\'export'),
|
||||
if (exportResult != null) {
|
||||
if (exportResult.isBanned) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.block, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Participation IA suspendue : ${exportResult.reason ?? "Non-respect des règles"}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
duration: const Duration(seconds: 6),
|
||||
),
|
||||
backgroundColor: exportSucceeded
|
||||
? AppTheme.successColor
|
||||
: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
);
|
||||
} else if (exportResult.isSuccess) {
|
||||
final targetStatus = exportResult.targetValidation?['status'];
|
||||
final isCertified = targetStatus == 'VALID';
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isCertified ? Icons.verified : Icons.cloud_done,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
isCertified
|
||||
? 'Export réussi ! Cible certifiée par l\'IA.'
|
||||
: exportResult.message,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text('Échec de l\'export : ${exportResult.message}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
|
||||
@@ -22,7 +22,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
String? _identityPhrase;
|
||||
int? _photoCount;
|
||||
bool _isLoadingStats = false;
|
||||
bool _isCheckingStatus = false;
|
||||
bool _isUploadEnabled = false;
|
||||
bool _isBanned = false;
|
||||
String? _banReason;
|
||||
String _serverUrl = 'http://localhost:3000';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -32,11 +36,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
Future<void> _loadIdentity() async {
|
||||
final phrase = await _walletService.getIdentityPhrase();
|
||||
final serverUrl = await _walletService.getServerBaseUrl();
|
||||
final isBanned = await _walletService.isBanned();
|
||||
final banReason = await _walletService.getBanReason();
|
||||
final isEnabled = await _walletService.isUploadEnabled();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_identityPhrase = phrase;
|
||||
_isUploadEnabled = isEnabled;
|
||||
_serverUrl = serverUrl;
|
||||
_isBanned = isBanned;
|
||||
_banReason = banReason;
|
||||
_isUploadEnabled = isEnabled && !isBanned;
|
||||
});
|
||||
_fetchStats(phrase);
|
||||
}
|
||||
@@ -47,19 +57,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
try {
|
||||
final phraseBytes = utf8.encode(phrase);
|
||||
final walletHash = sha256.convert(phraseBytes).toString();
|
||||
final baseUrl = await _walletService.getServerBaseUrl();
|
||||
|
||||
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost
|
||||
final baseUrl = Theme.of(context).platform == TargetPlatform.android
|
||||
? 'http://10.0.2.2:3000'
|
||||
: 'http://localhost:3000';
|
||||
|
||||
final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash'));
|
||||
final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash')).timeout(
|
||||
const Duration(seconds: 4),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_photoCount = data['stats']['photo_count'] ?? 0;
|
||||
_serverUrl = baseUrl;
|
||||
_isLoadingStats = false;
|
||||
});
|
||||
}
|
||||
@@ -72,6 +81,72 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Action manuelle de l'utilisateur pour vérifier et synchroniser son statut auprès du serveur
|
||||
Future<void> _refreshAccountStatus() async {
|
||||
setState(() => _isCheckingStatus = true);
|
||||
try {
|
||||
final isBanned = await _walletService.syncBanStatus();
|
||||
final banReason = await _walletService.getBanReason();
|
||||
final isEnabled = await _walletService.isUploadEnabled();
|
||||
|
||||
if (_identityPhrase != null) {
|
||||
await _fetchStats(_identityPhrase!);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isBanned = isBanned;
|
||||
_banReason = banReason;
|
||||
_isUploadEnabled = isEnabled && !isBanned;
|
||||
_isCheckingStatus = false;
|
||||
});
|
||||
|
||||
if (isBanned) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.block, color: Colors.white),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text('Compte toujours suspendu : ${_banReason ?? "Non-respect des règles"}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.white),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text('Statut actualisé : votre compte est actif et autorisé !'),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
duration: Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isCheckingStatus = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Impossible de joindre le serveur : $e'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _copyToClipboard() {
|
||||
if (_identityPhrase != null) {
|
||||
Clipboard.setData(ClipboardData(text: _identityPhrase!));
|
||||
@@ -182,8 +257,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
void _showOptInDisclaimer(bool value) {
|
||||
if (_isBanned) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.block, color: AppTheme.errorColor),
|
||||
SizedBox(width: 10),
|
||||
Expanded(child: Text('Programme IA Suspendu')),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'Votre participation au programme d\'entraînement a été suspendue par la modération pour le motif suivant :\n\n'
|
||||
'« ${_banReason ?? "Envois non conformes ou inappropriés"} »\n\n'
|
||||
'L\'envoi de photos pour l\'entraînement est définitivement désactivé sur cet appareil.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Compris'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
// Si on désactive, pas besoin de disclaimer, on le fait direct.
|
||||
_walletService.setUploadEnabled(false);
|
||||
setState(() {
|
||||
_isUploadEnabled = false;
|
||||
@@ -196,27 +297,68 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'En activant cette option, vous acceptez d\'envoyer vos photos de cibles à notre serveur sécurisé.',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique incassable.',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'🎁 Avantages Futurs : Votre contribution (Hash de Wallet) sera comptabilisée. Lors de la sortie publique de notre IA, les participants actifs recevront des fonctionnalités premium ou des badges exclusifs en récompense !',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
],
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Center(
|
||||
child: Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'En activant cette option, vous acceptez d\'envoyer vos photos de cibles au serveur d\'entraînement IA.',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique.',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'🎁 Récompenses : Vos contributions sont enregistrées pour vous donner accès à des fonctionnalités exclusives.',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.errorColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Règles strictes & Bannissement',
|
||||
style: TextStyle(
|
||||
color: AppTheme.errorColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'Vous vous engagez à n\'envoyer que de réelles cibles de tir conformes. '
|
||||
'Tout envoi de photos non conformes, fausses cibles, images floues ou contenu inapproprié '
|
||||
'entraînera le bannissement immédiat et définitif de votre compte. '
|
||||
'L\'application perdra définitivement la possibilité d\'envoyer des photos.',
|
||||
style: TextStyle(fontSize: 12, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -238,7 +380,70 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('J\'accepte', style: TextStyle(color: Colors.white)),
|
||||
child: const Text('J\'accepte les règles', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditServerUrlDialog() {
|
||||
final urlController = TextEditingController(text: _serverUrl);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Adresse du Serveur IA'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Indiquez l\'adresse IP ou l\'URL du serveur backend IA (port 3000) :',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: urlController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Ex: http://192.168.1.50:3000',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'💡 Sur émulateur : http://10.0.2.2:3000\n💡 Sur smartphone réel : IP locale de votre PC (ex: http://192.168.1.X:3000)',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor),
|
||||
onPressed: () async {
|
||||
final newUrl = urlController.text.trim();
|
||||
if (newUrl.isNotEmpty) {
|
||||
await _walletService.setServerBaseUrl(newUrl);
|
||||
setState(() {
|
||||
_serverUrl = newUrl;
|
||||
});
|
||||
if (_identityPhrase != null) {
|
||||
_fetchStats(_identityPhrase!);
|
||||
}
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Adresse du serveur IA mise à jour'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Enregistrer', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -363,51 +568,126 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionHeader('Configuration Serveur IA'),
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
||||
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
|
||||
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
|
||||
value: _isUploadEnabled,
|
||||
activeThumbColor: AppTheme.primaryColor,
|
||||
_buildSectionHeader('Programme d\'Entraînement IA'),
|
||||
if (_isBanned) ...[
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: AppTheme.errorColor.withValues(alpha: 0.12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
|
||||
side: BorderSide(color: AppTheme.errorColor.withValues(alpha: 0.6), width: 1.5),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.block, color: AppTheme.errorColor, size: 22),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Participation au programme IA suspendue',
|
||||
style: TextStyle(
|
||||
color: AppTheme.errorColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Motif : ${_banReason ?? "Envois d'images non appropriées ou fausses cibles."}',
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Suite à des envois non conformes, la possibilité de participer au programme d\'entraînement et d\'envoyer des photos a été révoquée pour cet identifiant.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey, height: 1.3),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
icon: _isCheckingStatus
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(
|
||||
_isCheckingStatus ? 'Vérification...' : 'ACTUALISER MON STATUT',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onPressed: _isCheckingStatus ? null : _refreshAccountStatus,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onChanged: _showOptInDisclaimer,
|
||||
),
|
||||
),
|
||||
if (_isUploadEnabled)
|
||||
_buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.cloud_outlined,
|
||||
title: 'Adresse du Serveur IA',
|
||||
subtitle: 'http://localhost:3000/api/upload',
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Changement d\'adresse à venir')),
|
||||
);
|
||||
},
|
||||
] else ...[
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
||||
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: const Text('Aidez-nous à améliorer la détection (soumis aux règles strictes)', style: TextStyle(fontSize: 12)),
|
||||
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
|
||||
value: _isUploadEnabled,
|
||||
activeThumbColor: AppTheme.primaryColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
|
||||
),
|
||||
onChanged: _showOptInDisclaimer,
|
||||
),
|
||||
),
|
||||
if (_isUploadEnabled)
|
||||
_buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.data_usage,
|
||||
title: 'Photos Exportées',
|
||||
subtitle: _isLoadingStats
|
||||
? 'Chargement...'
|
||||
: (_photoCount != null ? '$_photoCount photos envoyées à l\'IA' : 'Non disponible'),
|
||||
onTap: () {
|
||||
if (_identityPhrase != null) {
|
||||
_fetchStats(_identityPhrase!);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (_isUploadEnabled)
|
||||
_buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.cloud_outlined,
|
||||
title: 'Adresse du Serveur IA',
|
||||
subtitle: _serverUrl,
|
||||
onTap: _showEditServerUrlDialog,
|
||||
),
|
||||
if (_isUploadEnabled)
|
||||
_buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.sync,
|
||||
title: 'Actualiser mon statut',
|
||||
subtitle: _isCheckingStatus ? 'Vérification en cours...' : 'Vérifier l\'état du compte auprès du serveur',
|
||||
onTap: _isCheckingStatus ? () {} : _refreshAccountStatus,
|
||||
),
|
||||
if (_isUploadEnabled)
|
||||
_buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.data_usage,
|
||||
title: 'Photos Exportées',
|
||||
subtitle: _isLoadingStats
|
||||
? 'Chargement...'
|
||||
: (_photoCount != null ? '$_photoCount photos envoyées à l\'IA' : 'Non disponible'),
|
||||
onTap: () {
|
||||
if (_identityPhrase != null) {
|
||||
_fetchStats(_identityPhrase!);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionHeader('À propos'),
|
||||
|
||||
@@ -8,16 +8,62 @@ import '../data/models/shot.dart';
|
||||
import '../data/models/target_type.dart';
|
||||
import 'wallet_identity_service.dart';
|
||||
|
||||
class AiExportService {
|
||||
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost.
|
||||
// Pour un appareil physique, il faudra utiliser l'IP locale du PC (ex: 192.168.1.X).
|
||||
static String get _defaultApiUrl {
|
||||
if (Platform.isAndroid) {
|
||||
return 'http://10.0.2.2:3000/api/upload';
|
||||
}
|
||||
return 'http://localhost:3000/api/upload';
|
||||
/// Résultat détaillé de l'exportation vers le serveur IA
|
||||
class AiExportResult {
|
||||
final bool isSuccess;
|
||||
final String code;
|
||||
final String message;
|
||||
final String? reason;
|
||||
final bool isBanned;
|
||||
final Map<String, dynamic>? targetValidation;
|
||||
|
||||
AiExportResult({
|
||||
required this.isSuccess,
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.reason,
|
||||
this.isBanned = false,
|
||||
this.targetValidation,
|
||||
});
|
||||
|
||||
factory AiExportResult.success({
|
||||
String? message,
|
||||
Map<String, dynamic>? targetValidation,
|
||||
}) {
|
||||
return AiExportResult(
|
||||
isSuccess: true,
|
||||
code: 'UPLOAD_SUCCESS',
|
||||
message: message ?? 'Export réussi vers le serveur IA !',
|
||||
targetValidation: targetValidation,
|
||||
);
|
||||
}
|
||||
|
||||
factory AiExportResult.banned({
|
||||
String? reason,
|
||||
String? message,
|
||||
}) {
|
||||
return AiExportResult(
|
||||
isSuccess: false,
|
||||
code: 'WALLET_BANNED',
|
||||
isBanned: true,
|
||||
reason: reason,
|
||||
message: message ?? 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
|
||||
);
|
||||
}
|
||||
|
||||
factory AiExportResult.error({
|
||||
String? code,
|
||||
required String message,
|
||||
}) {
|
||||
return AiExportResult(
|
||||
isSuccess: false,
|
||||
code: code ?? 'UPLOAD_ERROR',
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AiExportService {
|
||||
/// Extrait les informations de l'appareil
|
||||
Future<Map<String, dynamic>> _getDeviceInfo() async {
|
||||
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||
@@ -45,7 +91,7 @@ class AiExportService {
|
||||
}
|
||||
|
||||
/// Exporte l'image et les données de plotting vers le serveur
|
||||
Future<bool> exportData({
|
||||
Future<AiExportResult> exportData({
|
||||
required String imagePath,
|
||||
required String sessionId,
|
||||
required TargetType targetType,
|
||||
@@ -58,23 +104,23 @@ class AiExportService {
|
||||
String? apiUrl,
|
||||
}) async {
|
||||
try {
|
||||
final url = Uri.parse(apiUrl ?? _defaultApiUrl);
|
||||
final walletService = WalletIdentityService();
|
||||
final baseUrl = await walletService.getServerBaseUrl();
|
||||
final effectiveUrl = apiUrl ?? '$baseUrl/api/upload';
|
||||
final url = Uri.parse(effectiveUrl);
|
||||
final request = http.MultipartRequest('POST', url);
|
||||
|
||||
// 1. Prepare image
|
||||
final file = File(imagePath);
|
||||
if (!await file.exists()) {
|
||||
throw Exception('Le fichier image n\'existe pas');
|
||||
return AiExportResult.error(
|
||||
code: 'FILE_NOT_FOUND',
|
||||
message: 'Le fichier image cible est introuvable.',
|
||||
);
|
||||
}
|
||||
|
||||
// Read image metadata (approximate dimensions since decoding image can be heavy)
|
||||
// On the frontend we usually have aspectRatio, here we use generic values if not available.
|
||||
final deviceData = await _getDeviceInfo();
|
||||
|
||||
// We approximate the target corners from center and radius
|
||||
// radius is relative (0 to 1). We need image width/height to get pixels.
|
||||
// But we can just pass relative corners as well, or a normalized bounding box.
|
||||
// Let's create normalized corners (0 to 1).
|
||||
final corners = [
|
||||
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
|
||||
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius},
|
||||
@@ -98,7 +144,6 @@ class AiExportService {
|
||||
}).toList();
|
||||
|
||||
// Get and hash the wallet identity
|
||||
final walletService = WalletIdentityService();
|
||||
final phrase = await walletService.getIdentityPhrase();
|
||||
final phraseBytes = utf8.encode(phrase);
|
||||
final walletHash = sha256.convert(phraseBytes).toString();
|
||||
@@ -113,7 +158,6 @@ class AiExportService {
|
||||
"type": targetType.name,
|
||||
"distance_meters": distanceMeters,
|
||||
"weapon": weaponName,
|
||||
// The backend could extract exact width/height from the image.
|
||||
},
|
||||
"plotting": {
|
||||
"target_corners": corners,
|
||||
@@ -121,29 +165,61 @@ class AiExportService {
|
||||
}
|
||||
};
|
||||
|
||||
// Add fields to request
|
||||
request.fields['plotting'] = jsonEncode(plottingJson);
|
||||
|
||||
// Add file
|
||||
request.files.add(
|
||||
await http.MultipartFile.fromPath('photo', imagePath),
|
||||
);
|
||||
|
||||
// Send request
|
||||
final response = await request.send();
|
||||
final streamedResponse = await request.send().timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () => throw Exception('Délai d\'attente dépassé (timeout)'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseData = await response.stream.bytesToString();
|
||||
debugPrint('Export réussi: $responseData');
|
||||
return true;
|
||||
final responseBody = await streamedResponse.stream.bytesToString();
|
||||
Map<String, dynamic> responseJson = {};
|
||||
try {
|
||||
responseJson = jsonDecode(responseBody);
|
||||
} catch (_) {}
|
||||
|
||||
final statusCode = streamedResponse.statusCode;
|
||||
|
||||
if (statusCode == 200) {
|
||||
debugPrint('Export réussi: $responseBody');
|
||||
return AiExportResult.success(
|
||||
message: responseJson['message'] ?? 'Photo et données exportées avec succès.',
|
||||
targetValidation: responseJson['target_validation'] as Map<String, dynamic>?,
|
||||
);
|
||||
} else if (statusCode == 403 || responseJson['code'] == 'WALLET_BANNED') {
|
||||
final reason = responseJson['reason'] ?? 'Non-respect des règles de contribution';
|
||||
debugPrint('Export rejeté (banni): $reason');
|
||||
// Persister le bannissement localement et couper l'envoi de photos
|
||||
await walletService.setBanned(true, reason: reason);
|
||||
return AiExportResult.banned(
|
||||
reason: reason,
|
||||
message: responseJson['error'] ?? 'Votre wallet a été suspendu par la modération.',
|
||||
);
|
||||
} else if (statusCode == 400) {
|
||||
return AiExportResult.error(
|
||||
code: responseJson['code'] ?? 'BAD_REQUEST',
|
||||
message: responseJson['error'] ?? 'Requête d\'export invalide.',
|
||||
);
|
||||
} else {
|
||||
final errorData = await response.stream.bytesToString();
|
||||
debugPrint('Erreur d\'export: ${response.statusCode} - $errorData');
|
||||
return false;
|
||||
return AiExportResult.error(
|
||||
code: responseJson['code'] ?? 'SERVER_ERROR',
|
||||
message: responseJson['error'] ?? 'Erreur serveur ($statusCode).',
|
||||
);
|
||||
}
|
||||
} on SocketException {
|
||||
return AiExportResult.error(
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'Impossible de joindre le serveur IA. Vérifiez l\'adresse IP ou votre connexion.',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Exception lors de l\'export: $e');
|
||||
return false;
|
||||
return AiExportResult.error(
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'Erreur lors de l\'export: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:math';
|
||||
import 'dart:convert';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'dart:io';
|
||||
@@ -9,6 +10,9 @@ import 'package:flutter/foundation.dart';
|
||||
class WalletIdentityService {
|
||||
static const String _prefsKey = 'wallet_identity_phrase';
|
||||
static const String _uploadEnabledKey = 'is_ai_upload_enabled';
|
||||
static const String _bannedKey = 'wallet_is_banned';
|
||||
static const String _banReasonKey = 'wallet_ban_reason';
|
||||
static const String _serverUrlKey = 'ai_server_url';
|
||||
|
||||
// A standard list of 256 words (8 bits of entropy per word)
|
||||
static const List<String> _wordList = [
|
||||
@@ -40,18 +44,96 @@ class WalletIdentityService {
|
||||
'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter'
|
||||
];
|
||||
|
||||
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA
|
||||
/// Retourne l'URL de base du serveur configuré (ex: http://192.168.1.50:3000 ou http://10.0.2.2:3000)
|
||||
Future<String> getServerBaseUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final customUrl = prefs.getString(_serverUrlKey);
|
||||
if (customUrl != null && customUrl.trim().isNotEmpty) {
|
||||
return customUrl.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
return 'http://10.0.2.2:3000';
|
||||
}
|
||||
return 'http://localhost:3000';
|
||||
}
|
||||
|
||||
/// Définit une URL personnalisée pour le serveur IA
|
||||
Future<void> setServerBaseUrl(String url) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cleanUrl = url.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
|
||||
await prefs.setString(_serverUrlKey, cleanUrl);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA (et n'est pas banni)
|
||||
Future<bool> isUploadEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isBanned = prefs.getBool(_bannedKey) ?? false;
|
||||
if (isBanned) return false;
|
||||
return prefs.getBool(_uploadEnabledKey) ?? false;
|
||||
}
|
||||
|
||||
/// Active ou désactive l'envoi de données
|
||||
Future<void> setUploadEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isBanned = prefs.getBool(_bannedKey) ?? false;
|
||||
if (isBanned) {
|
||||
await prefs.setBool(_uploadEnabledKey, false);
|
||||
return;
|
||||
}
|
||||
await prefs.setBool(_uploadEnabledKey, enabled);
|
||||
}
|
||||
|
||||
/// Vérifie si ce wallet/utilisateur est banni en local
|
||||
Future<bool> isBanned() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_bannedKey) ?? false;
|
||||
}
|
||||
|
||||
/// Récupère le motif de bannissement enregistré
|
||||
Future<String?> getBanReason() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_banReasonKey);
|
||||
}
|
||||
|
||||
/// Enregistre l'état de bannissement et le motif
|
||||
Future<void> setBanned(bool banned, {String? reason}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_bannedKey, banned);
|
||||
if (banned) {
|
||||
await prefs.setString(_banReasonKey, reason ?? 'Photos non conformes aux règles de tir');
|
||||
await prefs.setBool(_uploadEnabledKey, false);
|
||||
} else {
|
||||
await prefs.remove(_banReasonKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronise le statut de modération/bannissement avec le serveur backend
|
||||
Future<bool> syncBanStatus() async {
|
||||
try {
|
||||
final phrase = await getIdentityPhrase();
|
||||
final phraseBytes = utf8.encode(phrase);
|
||||
final walletHash = sha256.convert(phraseBytes).toString();
|
||||
final baseUrl = await getServerBaseUrl();
|
||||
|
||||
final res = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash')).timeout(
|
||||
const Duration(seconds: 4),
|
||||
);
|
||||
if (res.statusCode == 200) {
|
||||
final data = jsonDecode(res.body);
|
||||
final isBannedOnServer = data['is_banned'] == true;
|
||||
if (isBannedOnServer) {
|
||||
await setBanned(true, reason: data['ban_reason']);
|
||||
} else {
|
||||
await setBanned(false);
|
||||
}
|
||||
return isBannedOnServer;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur synchro ban: $e');
|
||||
}
|
||||
return await isBanned();
|
||||
}
|
||||
|
||||
/// Gets the unique 15-word identity phrase
|
||||
Future<String> getIdentityPhrase() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
Reference in New Issue
Block a user