refactor: UI improvements and code cleanup
Frontend: - DetectionsList: Simplify columns, improve truncation and display for IPs, hosts, bot info - IncidentsView: Replace metric cards with compact stat cards (unique IPs, known bots, ML anomalies, threat levels) - InvestigationView: Add section navigation anchors, reorganize layout with proper IDs - ThreatIntelView: Add navigation links to investigation pages, add comment column, improve table layout Backend: - Various route and model adjustments - Configuration updates Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@ -367,7 +367,7 @@ function MainContent({ counts: _counts }: { counts: AlertCounts | null }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 px-6 py-5 mt-14 overflow-auto">
|
||||
<main className="flex-1 px-4 py-3 mt-14 overflow-auto">
|
||||
<Routes>
|
||||
<Route path="/" element={<IncidentsView />} />
|
||||
<Route path="/incidents" element={<IncidentsView />} />
|
||||
|
||||
@ -114,7 +114,7 @@ export function CampaignsView() {
|
||||
const [subnetLoading, setSubnetLoading] = useState<Set<string>>(new Set());
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('clusters');
|
||||
const [minIPs, setMinIPs] = useState(3);
|
||||
const [minIPs, setMinIPs] = useState(1);
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('all');
|
||||
|
||||
// Fetch clusters on mount
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useDetections } from '../hooks/useDetections';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
@ -55,6 +55,14 @@ export function DetectionsList() {
|
||||
const scoreType = searchParams.get('score_type') || undefined;
|
||||
|
||||
const [groupByIP, setGroupByIP] = useState(true);
|
||||
const [threatDist, setThreatDist] = useState<{threat_level: string; count: number; percentage: number}[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/metrics/threats')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d?.items) setThreatDist(d.items); })
|
||||
.catch(() => null);
|
||||
}, []);
|
||||
|
||||
const { data, loading, error } = useDetections({
|
||||
page,
|
||||
@ -157,60 +165,36 @@ export function DetectionsList() {
|
||||
key: 'src_ip',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<div className="font-mono text-sm text-text-primary">{row.src_ip}</div>
|
||||
{groupByIP && row.unique_ja4s && row.unique_ja4s.length > 0 ? (
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="text-xs text-text-secondary font-medium">
|
||||
{row.unique_ja4s.length} JA4{row.unique_ja4s.length > 1 ? 's' : ''} unique{row.unique_ja4s.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
{row.unique_ja4s.slice(0, 3).map((ja4, idx) => (
|
||||
<div key={idx} className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
||||
{ja4}
|
||||
</div>
|
||||
))}
|
||||
{row.unique_ja4s.length > 3 && (
|
||||
<div className="font-mono text-xs text-text-disabled">
|
||||
+{row.unique_ja4s.length - 3} autre{row.unique_ja4s.length - 3 > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
width: 'w-[220px] min-w-[180px]',
|
||||
render: (_, row) => {
|
||||
const ja4s = groupByIP && row.unique_ja4s?.length ? row.unique_ja4s : row.ja4 ? [row.ja4] : [];
|
||||
const ja4Label = ja4s.length > 1 ? `${ja4s.length} JA4` : ja4s[0] ?? '—';
|
||||
return (
|
||||
<div>
|
||||
<div className="font-mono text-sm text-text-primary whitespace-nowrap">{row.src_ip}</div>
|
||||
<div className="font-mono text-xs text-text-disabled truncate max-w-[200px]" title={ja4s.join(' | ')}>
|
||||
{ja4Label}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
||||
{row.ja4 || '-'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
case 'host':
|
||||
return {
|
||||
key: 'host',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
render: (_, row) =>
|
||||
groupByIP && row.unique_hosts && row.unique_hosts.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary font-medium">
|
||||
{row.unique_hosts.length} Host{row.unique_hosts.length > 1 ? 's' : ''} unique{row.unique_hosts.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
{row.unique_hosts.slice(0, 3).map((host, idx) => (
|
||||
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
||||
{host}
|
||||
</div>
|
||||
))}
|
||||
{row.unique_hosts.length > 3 && (
|
||||
<div className="text-xs text-text-disabled">
|
||||
+{row.unique_hosts.length - 3} autre{row.unique_hosts.length - 3 > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
width: 'w-[180px] min-w-[140px]',
|
||||
render: (_, row) => {
|
||||
const hosts = groupByIP && row.unique_hosts?.length ? row.unique_hosts : row.host ? [row.host] : [];
|
||||
const primary = hosts[0] ?? '—';
|
||||
const extra = hosts.length > 1 ? ` +${hosts.length - 1}` : '';
|
||||
return (
|
||||
<div className="truncate max-w-[175px] text-sm text-text-primary" title={hosts.join(', ')}>
|
||||
{primary}<span className="text-text-disabled text-xs">{extra}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
||||
{row.host || '-'}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
case 'client_headers':
|
||||
return {
|
||||
@ -257,24 +241,18 @@ export function DetectionsList() {
|
||||
</span>
|
||||
),
|
||||
sortable: false,
|
||||
width: 'w-[140px]',
|
||||
render: (_, row) => {
|
||||
const name = row.anubis_bot_name;
|
||||
const action = row.anubis_bot_action;
|
||||
const category = row.anubis_bot_category;
|
||||
if (!name) return <span className="text-text-disabled text-xs">—</span>;
|
||||
const actionColor =
|
||||
action === 'ALLOW' ? 'bg-green-500/15 text-green-400 border-green-500/30' :
|
||||
action === 'DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30' :
|
||||
'bg-yellow-500/15 text-yellow-400 border-yellow-500/30';
|
||||
action === 'ALLOW' ? 'text-green-400' :
|
||||
action === 'DENY' ? 'text-red-400' : 'text-yellow-400';
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${actionColor}`}>
|
||||
<span className="font-medium">{name}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{action && <span className="text-[10px] text-text-secondary">{action}</span>}
|
||||
{category && <span className="text-[10px] text-text-disabled">· {category}</span>}
|
||||
</div>
|
||||
<div className="truncate max-w-[135px]" title={`${name} · ${action}`}>
|
||||
<span className={`text-xs font-medium ${actionColor}`}>{name}</span>
|
||||
{action && <span className="text-[10px] text-text-disabled ml-1">· {action}</span>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@ -330,13 +308,11 @@ export function DetectionsList() {
|
||||
key: 'asn_org',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
width: 'w-[150px]',
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<div className="text-sm text-text-primary">{row.asn_org || row.asn_number || '-'}</div>
|
||||
{row.asn_number && (
|
||||
<div className="text-xs text-text-secondary">AS{row.asn_number}</div>
|
||||
)}
|
||||
<AsnRepBadge score={row.asn_score} label={row.asn_rep_label} />
|
||||
<div className="truncate max-w-[145px]" title={`${row.asn_org ?? ''} AS${row.asn_number ?? ''}`}>
|
||||
<span className="text-sm text-text-primary">{row.asn_org || `AS${row.asn_number}` || '—'}</span>
|
||||
{row.asn_number && <span className="text-xs text-text-disabled ml-1">AS{row.asn_number}</span>}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@ -358,34 +334,18 @@ export function DetectionsList() {
|
||||
key: 'detected_at',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
render: (_, row) =>
|
||||
groupByIP && row.first_seen ? (() => {
|
||||
const first = new Date(row.first_seen!);
|
||||
width: 'w-[110px]',
|
||||
render: (_, row) => {
|
||||
if (groupByIP && row.first_seen) {
|
||||
const last = new Date(row.last_seen!);
|
||||
const sameTime = first.getTime() === last.getTime();
|
||||
const fmt = (d: Date) => formatDate(d.toISOString());
|
||||
return sameTime ? (
|
||||
<div className="text-xs text-text-secondary">{fmt(last)}</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary">
|
||||
<span className="font-medium">Premier:</span> {fmt(first)}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
<span className="font-medium">Dernier:</span> {fmt(last)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})() : (
|
||||
<>
|
||||
<div className="text-sm text-text-primary">
|
||||
{formatDateOnly(row.detected_at)}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
{formatTimeOnly(row.detected_at)}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
return <div className="text-xs text-text-secondary whitespace-nowrap">{formatDate(last.toISOString())}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="text-xs text-text-secondary whitespace-nowrap">
|
||||
{formatDateOnly(row.detected_at)} {formatTimeOnly(row.detected_at)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
default:
|
||||
return { key: col.key, label: col.label, sortable: col.sortable };
|
||||
@ -393,118 +353,143 @@ export function DetectionsList() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* En-tête */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Détections</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>{data.items.length}</span>
|
||||
<span>→</span>
|
||||
<span>{data.total} détections</span>
|
||||
</div>
|
||||
<div className="space-y-2 animate-fade-in">
|
||||
|
||||
{/* ── Barre unique : titre + pills + filtres + recherche ── */}
|
||||
<div className="flex flex-wrap items-center gap-2 bg-background-secondary rounded-lg px-3 py-2">
|
||||
|
||||
{/* Titre + compteur */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="font-semibold text-text-primary">Détections</span>
|
||||
<span className="text-xs text-text-disabled bg-background-card rounded px-1.5 py-0.5">
|
||||
{data.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Toggle Grouper par IP */}
|
||||
<div className="w-px h-5 bg-background-card shrink-0" />
|
||||
|
||||
{/* Pills distribution */}
|
||||
{threatDist.map(({ threat_level, count, percentage }) => {
|
||||
const label = threat_level === 'KNOWN_BOT' ? '🤖 BOT' :
|
||||
threat_level === 'ANUBIS_DENY' ? '🔴 RÈGLE' :
|
||||
threat_level === 'HIGH' ? '⚠️ HIGH' :
|
||||
threat_level === 'MEDIUM' ? '📊 MED' :
|
||||
threat_level === 'CRITICAL' ? '🔥 CRIT' : threat_level;
|
||||
const style = threat_level === 'KNOWN_BOT' ? 'bg-green-500/15 text-green-400 border-green-500/30 hover:bg-green-500/25' :
|
||||
threat_level === 'ANUBIS_DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30 hover:bg-red-500/25' :
|
||||
threat_level === 'HIGH' ? 'bg-orange-500/15 text-orange-400 border-orange-500/30 hover:bg-orange-500/25' :
|
||||
threat_level === 'MEDIUM' ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30 hover:bg-yellow-500/25' :
|
||||
threat_level === 'CRITICAL' ? 'bg-red-700/15 text-red-300 border-red-700/30 hover:bg-red-700/25' :
|
||||
'bg-background-card text-text-secondary border-background-card';
|
||||
const filterVal = threat_level === 'KNOWN_BOT' ? 'BOT' : threat_level === 'ANUBIS_DENY' ? 'REGLE' : null;
|
||||
const active = filterVal && scoreType === filterVal;
|
||||
return (
|
||||
<button
|
||||
key={threat_level}
|
||||
onClick={() => {
|
||||
if (filterVal) handleFilterChange('score_type', scoreType === filterVal ? '' : filterVal);
|
||||
else handleFilterChange('threat_level', threat_level);
|
||||
}}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-xs font-medium transition-colors ${style} ${active ? 'ring-1 ring-offset-1 ring-current' : ''}`}
|
||||
>
|
||||
{label} <span className="font-bold">{count.toLocaleString()}</span>
|
||||
<span className="opacity-50">{percentage.toFixed(0)}%</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="w-px h-5 bg-background-card shrink-0" />
|
||||
|
||||
{/* Filtres select */}
|
||||
<select
|
||||
value={modelName || ''}
|
||||
onChange={(e) => handleFilterChange('model_name', e.target.value)}
|
||||
className="bg-background-card border border-background-card rounded px-2 py-1 text-text-primary text-xs focus:outline-none focus:border-accent-primary"
|
||||
>
|
||||
<option value="">Tous modèles</option>
|
||||
<option value="Complet">Complet</option>
|
||||
<option value="Applicatif">Applicatif</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={scoreType || ''}
|
||||
onChange={(e) => handleFilterChange('score_type', e.target.value)}
|
||||
className="bg-background-card border border-background-card rounded px-2 py-1 text-text-primary text-xs focus:outline-none focus:border-accent-primary"
|
||||
>
|
||||
<option value="">Tous scores</option>
|
||||
<option value="BOT">🟢 BOT</option>
|
||||
<option value="REGLE">🔴 RÈGLE</option>
|
||||
<option value="BOT_REGLE">BOT+RÈGLE</option>
|
||||
<option value="SCORE">Score num.</option>
|
||||
</select>
|
||||
|
||||
{(modelName || scoreType || search || sortField !== 'detected_at') && (
|
||||
<button
|
||||
onClick={() => setGroupByIP(!groupByIP)}
|
||||
className={`border rounded-lg px-4 py-2 text-sm transition-colors ${
|
||||
groupByIP
|
||||
? 'bg-accent-primary text-white border-accent-primary'
|
||||
: 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
|
||||
}`}
|
||||
title={groupByIP ? 'Passer en vue détections individuelles' : 'Passer en vue groupée par IP'}
|
||||
onClick={() => setSearchParams({})}
|
||||
className="text-xs text-text-secondary hover:text-text-primary bg-background-card rounded px-2 py-1 border border-background-card transition-colors"
|
||||
>
|
||||
{groupByIP ? '⊞ Vue : Groupé par IP' : '⊟ Vue : Individuelle'}
|
||||
✕ Effacer
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Sélecteur de colonnes */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowColumnSelector(!showColumnSelector)}
|
||||
className="bg-background-card hover:bg-background-card/80 border border-background-card rounded-lg px-4 py-2 text-text-primary transition-colors"
|
||||
>
|
||||
Colonnes ▾
|
||||
</button>
|
||||
|
||||
{showColumnSelector && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-background-secondary border border-background-card rounded-lg shadow-lg z-10 p-2">
|
||||
<p className="text-xs text-text-secondary mb-2 px-2">Afficher les colonnes</p>
|
||||
{columns.map(col => (
|
||||
<label
|
||||
key={col.key}
|
||||
className="flex items-center gap-2 px-2 py-1.5 hover:bg-background-card rounded cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.visible}
|
||||
onChange={() => toggleColumn(col.key)}
|
||||
className="rounded bg-background-card border-background-card text-accent-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-primary">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Recherche */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Rechercher IP, JA4, Host..."
|
||||
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-64"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Rechercher
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/* Toggle grouper */}
|
||||
<button
|
||||
onClick={() => setGroupByIP(!groupByIP)}
|
||||
className={`text-xs border rounded px-2 py-1 transition-colors shrink-0 ${
|
||||
groupByIP ? 'bg-accent-primary text-white border-accent-primary' : 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
|
||||
}`}
|
||||
title={groupByIP ? 'Vue individuelle' : 'Vue groupée par IP'}
|
||||
>
|
||||
{groupByIP ? '⊞ Groupé' : '⊟ Individuel'}
|
||||
</button>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="bg-background-secondary rounded-lg p-4">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<select
|
||||
value={modelName || ''}
|
||||
onChange={(e) => handleFilterChange('model_name', e.target.value)}
|
||||
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-accent-primary"
|
||||
{/* Sélecteur colonnes */}
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => setShowColumnSelector(!showColumnSelector)}
|
||||
className="text-xs bg-background-card hover:bg-background-card/80 border border-background-card rounded px-2 py-1 text-text-primary transition-colors"
|
||||
>
|
||||
<option value="">Tous modèles</option>
|
||||
<option value="Complet">Complet</option>
|
||||
<option value="Applicatif">Applicatif</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={scoreType || ''}
|
||||
onChange={(e) => handleFilterChange('score_type', e.target.value)}
|
||||
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-accent-primary"
|
||||
>
|
||||
<option value="">Tous types de score</option>
|
||||
<option value="BOT">🟢 BOT seulement</option>
|
||||
<option value="REGLE">🔴 RÈGLE seulement</option>
|
||||
<option value="BOT_REGLE">BOT + RÈGLE</option>
|
||||
<option value="SCORE">Score numérique seulement</option>
|
||||
</select>
|
||||
|
||||
{(modelName || scoreType || search || sortField !== 'detected_at') && (
|
||||
<button
|
||||
onClick={() => setSearchParams({})}
|
||||
className="bg-background-card hover:bg-background-card/80 border border-background-card rounded-lg px-4 py-2 text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
Effacer filtres
|
||||
</button>
|
||||
Colonnes ▾
|
||||
</button>
|
||||
{showColumnSelector && (
|
||||
<div className="absolute right-0 mt-1 w-44 bg-background-secondary border border-background-card rounded-lg shadow-lg z-20 p-2">
|
||||
{columns.map(col => (
|
||||
<label key={col.key} className="flex items-center gap-2 px-2 py-1 hover:bg-background-card rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.visible}
|
||||
onChange={() => toggleColumn(col.key)}
|
||||
className="rounded bg-background-card border-background-card text-accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-text-primary">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recherche */}
|
||||
<form onSubmit={handleSearch} className="flex gap-1 shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="IP, JA4, Host..."
|
||||
className="bg-background-card border border-background-card rounded px-2 py-1 text-xs text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-40"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white text-xs px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
{/* ── Tableau ── */}
|
||||
<div className="bg-background-secondary rounded-lg overflow-x-auto">
|
||||
<DataTable<DetectionRow>
|
||||
data={processedData.items as DetectionRow[]}
|
||||
@ -519,24 +504,24 @@ export function DetectionsList() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{/* ── Pagination ── */}
|
||||
{data.total_pages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-text-secondary text-sm">
|
||||
Page {data.page} sur {data.total_pages} ({data.total} détections)
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-text-secondary text-xs">
|
||||
Page {data.page}/{data.total_pages} · {data.total.toLocaleString()} détections
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handlePageChange(data.page - 1)}
|
||||
disabled={data.page === 1}
|
||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary px-4 py-2 rounded-lg transition-colors"
|
||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs px-3 py-1.5 rounded transition-colors"
|
||||
>
|
||||
← Précédent
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(data.page + 1)}
|
||||
disabled={data.page === data.total_pages}
|
||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary px-4 py-2 rounded-lg transition-colors"
|
||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs px-3 py-1.5 rounded transition-colors"
|
||||
>
|
||||
Suivant →
|
||||
</button>
|
||||
@ -611,27 +596,3 @@ function getFlag(countryCode: string): string {
|
||||
const code = countryCode.toUpperCase();
|
||||
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
}
|
||||
|
||||
// Badge de réputation ASN
|
||||
function AsnRepBadge({ score, label }: { score?: number | null; label?: string }) {
|
||||
if (score == null) return null;
|
||||
let bg: string;
|
||||
let text: string;
|
||||
let display: string;
|
||||
if (score < 0.3) {
|
||||
bg = 'bg-threat-critical/20';
|
||||
text = 'text-threat-critical';
|
||||
} else if (score < 0.6) {
|
||||
bg = 'bg-threat-medium/20';
|
||||
text = 'text-threat-medium';
|
||||
} else {
|
||||
bg = 'bg-threat-low/20';
|
||||
text = 'text-threat-low';
|
||||
}
|
||||
display = label || (score < 0.3 ? 'malicious' : score < 0.6 ? 'suspect' : 'ok');
|
||||
return (
|
||||
<span className={`mt-1 inline-block text-xs px-1.5 py-0.5 rounded ${bg} ${text}`}>
|
||||
{display}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,6 +30,8 @@ interface MetricsSummary {
|
||||
medium_count: number;
|
||||
low_count: number;
|
||||
unique_ips: number;
|
||||
known_bots_count: number;
|
||||
anomalies_count: number;
|
||||
}
|
||||
|
||||
interface BaselineMetric {
|
||||
@ -135,81 +137,114 @@ export function IncidentsView() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header with Quick Search */}
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">SOC Dashboard</h1>
|
||||
<p className="text-text-secondary text-sm mt-1">
|
||||
Surveillance en temps réel - 24 dernières heures
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm mt-1">Surveillance en temps réel · 24 dernières heures</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Baseline comparison */}
|
||||
{baseline && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{([
|
||||
{ key: 'total_detections', label: 'Détections 24h', icon: '📊', tip: TIPS.total_detections_stat },
|
||||
{ key: 'unique_ips', label: 'IPs uniques', icon: '🖥️', tip: TIPS.unique_ips_stat },
|
||||
{ key: 'critical_alerts', label: 'Alertes CRITICAL', icon: '🔴', tip: TIPS.risk_critical },
|
||||
] as { key: keyof BaselineData; label: string; icon: string; tip: string }[]).map(({ key, label, icon, tip }) => {
|
||||
const m = baseline[key];
|
||||
{/* Stats unifiées — 6 cartes compact */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{/* Total détections avec comparaison hier */}
|
||||
<div
|
||||
className="bg-background-card border border-border rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-accent-primary/50 transition-colors"
|
||||
onClick={() => navigate('/detections')}
|
||||
>
|
||||
<div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
|
||||
📊 Total 24h<InfoTip content={TIPS.total_detections_stat} />
|
||||
</div>
|
||||
<div className="text-xl font-bold text-text-primary">
|
||||
{(metrics?.total_detections ?? 0).toLocaleString()}
|
||||
</div>
|
||||
{baseline && (() => {
|
||||
const m = baseline.total_detections;
|
||||
const up = m.pct_change > 0;
|
||||
const neutral = m.pct_change === 0;
|
||||
return (
|
||||
<div key={key} className="bg-background-card border border-border rounded-lg px-4 py-3 flex items-center gap-3">
|
||||
<span className="text-xl">{icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-text-disabled uppercase tracking-wide flex items-center gap-1">{label}<InfoTip content={tip} /></div>
|
||||
<div className="text-xl font-bold text-text-primary">{m.today.toLocaleString(navigator.language || undefined)}</div>
|
||||
<div className="text-xs text-text-secondary">hier: {m.yesterday.toLocaleString(navigator.language || undefined)}</div>
|
||||
</div>
|
||||
<div className={`text-sm font-bold px-2 py-1 rounded ${
|
||||
neutral ? 'text-text-disabled' :
|
||||
up ? 'text-threat-critical bg-threat-critical/10' :
|
||||
'text-threat-low bg-threat-low/10'
|
||||
}`}>
|
||||
{neutral ? '=' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`}
|
||||
</div>
|
||||
<div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
|
||||
{neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`} vs hier
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Critical Metrics */}
|
||||
{metrics && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
title="CRITICAL"
|
||||
value={metrics.critical_count.toLocaleString()}
|
||||
subtitle={metrics.critical_count > 0 ? 'Requiert action immédiate' : 'Aucune'}
|
||||
color="bg-red-500/20"
|
||||
trend={metrics.critical_count > 10 ? 'up' : 'stable'}
|
||||
/>
|
||||
<MetricCard
|
||||
title="HIGH"
|
||||
value={metrics.high_count.toLocaleString()}
|
||||
subtitle="Menaces élevées"
|
||||
color="bg-orange-500/20"
|
||||
trend="stable"
|
||||
/>
|
||||
<MetricCard
|
||||
title="MEDIUM"
|
||||
value={metrics.medium_count.toLocaleString()}
|
||||
subtitle="Menaces moyennes"
|
||||
color="bg-yellow-500/20"
|
||||
trend="stable"
|
||||
/>
|
||||
<MetricCard
|
||||
title="TOTAL"
|
||||
value={metrics.total_detections.toLocaleString()}
|
||||
subtitle={`${metrics.unique_ips.toLocaleString()} IPs uniques`}
|
||||
color="bg-blue-500/20"
|
||||
trend="stable"
|
||||
/>
|
||||
{/* IPs uniques */}
|
||||
<div
|
||||
className="bg-background-card border border-border rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-accent-primary/50 transition-colors"
|
||||
onClick={() => navigate('/detections')}
|
||||
>
|
||||
<div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
|
||||
🖥️ IPs uniques<InfoTip content={TIPS.unique_ips_stat} />
|
||||
</div>
|
||||
<div className="text-xl font-bold text-text-primary">
|
||||
{(metrics?.unique_ips ?? 0).toLocaleString()}
|
||||
</div>
|
||||
{baseline && (() => {
|
||||
const m = baseline.unique_ips;
|
||||
const up = m.pct_change > 0;
|
||||
const neutral = m.pct_change === 0;
|
||||
return (
|
||||
<div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
|
||||
{neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`} vs hier
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BOT connus */}
|
||||
<div
|
||||
className="bg-green-500/10 border border-green-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-green-500/60 transition-colors"
|
||||
onClick={() => navigate('/detections?score_type=BOT')}
|
||||
>
|
||||
<div className="text-[10px] text-green-400/80 uppercase tracking-wide">🤖 BOT nommés</div>
|
||||
<div className="text-xl font-bold text-green-400">
|
||||
{(metrics?.known_bots_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-green-400/60">
|
||||
{metrics ? Math.round((metrics.known_bots_count / metrics.total_detections) * 100) : 0}% du total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anomalies ML */}
|
||||
<div
|
||||
className="bg-purple-500/10 border border-purple-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-purple-500/60 transition-colors"
|
||||
onClick={() => navigate('/detections?score_type=SCORE')}
|
||||
>
|
||||
<div className="text-[10px] text-purple-400/80 uppercase tracking-wide">🔬 Anomalies ML</div>
|
||||
<div className="text-xl font-bold text-purple-400">
|
||||
{(metrics?.anomalies_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-purple-400/60">
|
||||
{metrics ? Math.round((metrics.anomalies_count / metrics.total_detections) * 100) : 0}% du total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HIGH */}
|
||||
<div
|
||||
className="bg-orange-500/10 border border-orange-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-orange-500/60 transition-colors"
|
||||
onClick={() => navigate('/detections?threat_level=HIGH')}
|
||||
>
|
||||
<div className="text-[10px] text-orange-400/80 uppercase tracking-wide">⚠️ HIGH</div>
|
||||
<div className="text-xl font-bold text-orange-400">
|
||||
{(metrics?.high_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-orange-400/60">Menaces élevées</div>
|
||||
</div>
|
||||
|
||||
{/* MEDIUM */}
|
||||
<div
|
||||
className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-yellow-500/60 transition-colors"
|
||||
onClick={() => navigate('/detections?threat_level=MEDIUM')}
|
||||
>
|
||||
<div className="text-[10px] text-yellow-400/80 uppercase tracking-wide">📊 MEDIUM</div>
|
||||
<div className="text-xl font-bold text-yellow-400">
|
||||
{(metrics?.medium_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-yellow-400/60">Menaces moyennes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selectedClusters.size > 0 && (
|
||||
@ -478,34 +513,6 @@ export function IncidentsView() {
|
||||
);
|
||||
}
|
||||
|
||||
// Metric Card Component
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
color,
|
||||
trend
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle: string;
|
||||
color: string;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}) {
|
||||
return (
|
||||
<div className={`${color} rounded-lg p-6`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-text-secondary text-sm font-medium">{title}</h3>
|
||||
<span className="text-lg">
|
||||
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-text-primary">{value}</p>
|
||||
<p className="text-text-disabled text-xs mt-2">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mini Heatmap ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface HeatmapHour {
|
||||
|
||||
@ -74,9 +74,9 @@ function MiniTimeline({ data }: { data: { hour: number; hits: number }[] }) {
|
||||
}
|
||||
|
||||
function IPActivitySummary({ ip }: { ip: string }) {
|
||||
const [data, setData] = useState<IPSummary | null>(null);
|
||||
const [open, setOpen] = useState(false); // fermée par défaut
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(true);
|
||||
const [data, setData] = useState<IPSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@ -327,7 +327,7 @@ function Metric({ label, value, accent }: { label: string; value: string; accent
|
||||
}
|
||||
|
||||
function DetectionAttributesSection({ ip }: { ip: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(true); // ouvert par défaut
|
||||
const { data, loading } = useVariability('ip', ip);
|
||||
|
||||
const first = data?.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
|
||||
@ -448,38 +448,59 @@ export function InvestigationView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation ancres inter-sections */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 text-xs font-medium sticky top-0 z-10 bg-background py-2">
|
||||
<span className="text-text-disabled shrink-0">Aller à :</span>
|
||||
{[
|
||||
{ id: 'section-attributs', label: '📡 Attributs' },
|
||||
{ id: 'section-synthese', label: '🔎 Synthèse' },
|
||||
{ id: 'section-reputation', label: '🌍 Réputation' },
|
||||
{ id: 'section-correlations', label: '🕸️ Corrélations' },
|
||||
{ id: 'section-geo', label: '🌐 Géo / JA4' },
|
||||
{ id: 'section-classification', label: '🏷️ Classification' },
|
||||
].map(({ id, label }) => (
|
||||
<a key={id} href={`#${id}`} className="shrink-0 px-3 py-1 rounded-full bg-background-card text-text-secondary hover:text-text-primary hover:bg-background-secondary transition-colors">
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Attributs détectés (ex-DetailsView) */}
|
||||
<DetectionAttributesSection ip={ip} />
|
||||
<div id="section-attributs">
|
||||
<DetectionAttributesSection ip={ip} />
|
||||
</div>
|
||||
|
||||
{/* Ligne 0 : Synthèse multi-sources */}
|
||||
<IPActivitySummary ip={ip} />
|
||||
{/* Synthèse multi-sources */}
|
||||
<div id="section-synthese">
|
||||
<IPActivitySummary ip={ip} />
|
||||
</div>
|
||||
|
||||
{/* Ligne 1 : Réputation (1/3) + Graph de corrélations (2/3) */}
|
||||
<div className="grid grid-cols-3 gap-6 items-start">
|
||||
{/* Réputation (1/3) + Graph de corrélations (2/3) */}
|
||||
<div id="section-reputation" className="grid grid-cols-3 gap-6 items-start">
|
||||
<div className="bg-background-secondary rounded-lg p-6 h-full">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">🌍 Réputation IP</h3>
|
||||
<ReputationPanel ip={ip} />
|
||||
</div>
|
||||
<div className="col-span-2 bg-background-secondary rounded-lg p-6">
|
||||
<div id="section-correlations" className="col-span-2 bg-background-secondary rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">🕸️ Graph de Corrélations</h3>
|
||||
<CorrelationGraph ip={ip} height="600px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ligne 2 : Subnet / Country / JA4 (3 colonnes) */}
|
||||
<div className="grid grid-cols-3 gap-6 items-start">
|
||||
{/* Subnet / Country / JA4 */}
|
||||
<div id="section-geo" className="grid grid-cols-3 gap-6 items-start">
|
||||
<SubnetAnalysis ip={ip} />
|
||||
<CountryAnalysis ip={ip} />
|
||||
<JA4Analysis ip={ip} />
|
||||
</div>
|
||||
|
||||
{/* Ligne 3 : User-Agents (1/2) + Classification (1/2) */}
|
||||
<div className="grid grid-cols-2 gap-6 items-start">
|
||||
{/* User-Agents (1/2) + Classification (1/2) */}
|
||||
<div id="section-classification" className="grid grid-cols-2 gap-6 items-start">
|
||||
<UserAgentAnalysis ip={ip} />
|
||||
<CorrelationSummary ip={ip} onClassify={handleClassify} />
|
||||
</div>
|
||||
|
||||
{/* Ligne 4 : Cohérence JA4/UA (spoofing) */}
|
||||
{/* Cohérence JA4/UA (spoofing) */}
|
||||
<div className="grid grid-cols-3 gap-6 items-start">
|
||||
<FingerprintCoherenceWidget ip={ip} />
|
||||
<div className="col-span-2 bg-background-secondary rounded-lg p-5">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatDateShort } from '../utils/dateUtils';
|
||||
@ -22,6 +23,7 @@ interface ClassificationStats {
|
||||
}
|
||||
|
||||
export function ThreatIntelView() {
|
||||
const navigate = useNavigate();
|
||||
const [classifications, setClassifications] = useState<Classification[]>([]);
|
||||
const [stats, setStats] = useState<ClassificationStats[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -232,20 +234,28 @@ export function ThreatIntelView() {
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Entité</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Label</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Tags</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Commentaire</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase"><span className="flex items-center gap-1">Confiance<InfoTip content={TIPS.confiance} /></span></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Analyste</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-background-card">
|
||||
{filteredClassifications.slice(0, 50).map((classification, idx) => (
|
||||
{filteredClassifications.slice(0, 50).map((classification, idx) => {
|
||||
const entity = classification.ip || classification.ja4;
|
||||
const isIP = !!classification.ip;
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-background-card/50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||
<td className="px-4 py-3 text-sm text-text-secondary whitespace-nowrap">
|
||||
{formatDateShort(classification.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-mono text-sm text-text-primary">
|
||||
{classification.ip || classification.ja4}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(isIP ? `/investigation/${encodeURIComponent(entity!)}` : `/investigation/ja4/${encodeURIComponent(entity!)}`)}
|
||||
className="font-mono text-sm text-accent-primary hover:underline text-left truncate max-w-[160px] block"
|
||||
title={entity}
|
||||
>
|
||||
{entity}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-bold ${getLabelColor(classification.label)}`}>
|
||||
@ -254,17 +264,22 @@ export function ThreatIntelView() {
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{classification.tags.slice(0, 5).map((tag, tagIdx) => (
|
||||
{classification.tags.slice(0, 4).map((tag, tagIdx) => (
|
||||
<span key={tagIdx} className={`px-2 py-0.5 rounded text-xs ${getTagColor(tag)}`}>{tag}</span>
|
||||
))}
|
||||
{classification.tags.length > 5 && (
|
||||
<span className="text-xs text-text-secondary">+{classification.tags.length - 5}</span>
|
||||
{classification.tags.length > 4 && (
|
||||
<span className="text-xs text-text-secondary">+{classification.tags.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary max-w-[200px]">
|
||||
<span className="truncate block" title={(classification as any).comment || ''}>
|
||||
{(classification as any).comment || <span className="text-text-disabled">—</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-background-secondary rounded-full h-2">
|
||||
<div className="flex-1 bg-background-secondary rounded-full h-2 min-w-[60px]">
|
||||
<div className="h-2 rounded-full bg-accent-primary" style={{ width: `${classification.confidence * 100}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-text-primary font-bold">{(classification.confidence * 100).toFixed(0)}%</span>
|
||||
@ -272,7 +287,8 @@ export function ThreatIntelView() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{classification.analyst}</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user