- CampaignsView: update ClusterData interface to match real API response
(severity/unique_ips/score instead of threat_level/total_ips/confidence_range)
Fix fetch to use data.items, rewrite ClusterCard and BehavioralTab
Remove unused getClassificationColor and THREAT_ORDER constants
- analysis.py: fix IPv4Address object has no attribute 'split' on line 322
Add str() conversion before calling .split('.')
- entities.py: fix Date vs DateTime comparison — log_date is a Date column,
comparing against now()-INTERVAL HOUR caused yesterday's entries to be excluded
Use toDate(now() - INTERVAL X HOUR) for correct Date-level comparison
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
import { useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { VariabilityAttributes, AttributeValue } from '../api/client';
|
||
|
||
interface VariabilityPanelProps {
|
||
attributes: VariabilityAttributes;
|
||
}
|
||
|
||
export function VariabilityPanel({ attributes }: VariabilityPanelProps) {
|
||
const [showModal, setShowModal] = useState<{
|
||
type: string;
|
||
title: string;
|
||
items: string[];
|
||
total: number;
|
||
} | null>(null);
|
||
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
// Fonction pour charger la liste des IPs associées
|
||
const loadAssociatedIPs = async (attrType: string, value: string, total: number) => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await fetch(`/api/variability/${attrType}/${encodeURIComponent(value)}/ips?limit=100`);
|
||
const data = await response.json();
|
||
setShowModal({
|
||
type: 'ips',
|
||
title: `${data.total || total} IPs associées à ${value}`,
|
||
items: data.ips || [],
|
||
total: data.total || total,
|
||
});
|
||
} catch (error) {
|
||
console.error('Erreur chargement IPs:', error);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<h2 className="text-xl font-semibold text-text-primary">Variabilité des Attributs</h2>
|
||
|
||
{/* JA4 Fingerprints */}
|
||
{attributes.ja4 && attributes.ja4.length > 0 && (
|
||
<AttributeSection
|
||
title="JA4 Fingerprints"
|
||
items={attributes.ja4}
|
||
getValue={(item) => item.value}
|
||
getLink={(item) => `/investigation/ja4/${encodeURIComponent(item.value)}`}
|
||
onViewAll={(value, count) => loadAssociatedIPs('ja4', value, count)}
|
||
showViewAll
|
||
viewAllLabel="Voir les IPs"
|
||
/>
|
||
)}
|
||
|
||
{/* User-Agents */}
|
||
{attributes.user_agents && attributes.user_agents.length > 0 && (
|
||
<UASection items={attributes.user_agents} />
|
||
)}
|
||
|
||
{/* Pays */}
|
||
{attributes.countries && attributes.countries.length > 0 && (
|
||
<AttributeSection
|
||
title="Pays"
|
||
items={attributes.countries}
|
||
getValue={(item) => item.value}
|
||
getLink={(item) => `/detections/country/${encodeURIComponent(item.value)}`}
|
||
onViewAll={(value, count) => loadAssociatedIPs('country', value, count)}
|
||
showViewAll
|
||
viewAllLabel="Voir les IPs"
|
||
/>
|
||
)}
|
||
|
||
{/* ASN */}
|
||
{attributes.asns && attributes.asns.length > 0 && (
|
||
<AttributeSection
|
||
title="ASN"
|
||
items={attributes.asns}
|
||
getValue={(item) => item.value}
|
||
getLink={(item) => {
|
||
const asnNumber = item.value.match(/AS(\d+)/)?.[1] || item.value;
|
||
return `/detections/asn/${encodeURIComponent(asnNumber)}`;
|
||
}}
|
||
onViewAll={(value, count) => loadAssociatedIPs('asn', value, count)}
|
||
showViewAll
|
||
viewAllLabel="Voir les IPs"
|
||
/>
|
||
)}
|
||
|
||
{/* Hosts */}
|
||
{attributes.hosts && attributes.hosts.length > 0 && (
|
||
<AttributeSection
|
||
title="Hosts"
|
||
items={attributes.hosts}
|
||
getValue={(item) => item.value}
|
||
getLink={(item) => `/detections/host/${encodeURIComponent(item.value)}`}
|
||
onViewAll={(value, count) => loadAssociatedIPs('host', value, count)}
|
||
showViewAll
|
||
viewAllLabel="Voir les IPs"
|
||
/>
|
||
)}
|
||
|
||
{/* Threat Levels */}
|
||
{attributes.threat_levels && attributes.threat_levels.length > 0 && (
|
||
<AttributeSection
|
||
title="Niveaux de Menace"
|
||
items={attributes.threat_levels}
|
||
getValue={(item) => item.value}
|
||
getLink={(item) => `/detections?threat_level=${encodeURIComponent(item.value)}`}
|
||
onViewAll={(value, count) => loadAssociatedIPs('threat_level', value, count)}
|
||
showViewAll
|
||
viewAllLabel="Voir les IPs"
|
||
/>
|
||
)}
|
||
|
||
{/* Modal pour afficher la liste complète */}
|
||
{showModal && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-background-secondary rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between p-6 border-b border-background-card">
|
||
<h3 className="text-xl font-semibold text-text-primary">{showModal.title}</h3>
|
||
<button
|
||
onClick={() => setShowModal(null)}
|
||
className="text-text-secondary hover:text-text-primary transition-colors text-xl"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
{loading ? (
|
||
<div className="text-center text-text-secondary py-8">Chargement...</div>
|
||
) : showModal.items.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{showModal.items.map((item, index) => (
|
||
<div
|
||
key={index}
|
||
className="bg-background-card rounded-lg p-3 font-mono text-sm text-text-primary break-all"
|
||
>
|
||
{item}
|
||
</div>
|
||
))}
|
||
{showModal.total > showModal.items.length && (
|
||
<p className="text-center text-text-secondary text-sm mt-4">
|
||
Affichage de {showModal.items.length} sur {showModal.total} éléments
|
||
</p>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-center text-text-secondary py-8">
|
||
Aucune donnée disponible
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="p-4 border-t border-background-card text-right">
|
||
<button
|
||
onClick={() => setShowModal(null)}
|
||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-6 py-2 rounded-lg transition-colors"
|
||
>
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Composant UASection — jamais de troncature, expand/collapse
|
||
function UASection({ items }: { items: AttributeValue[] }) {
|
||
const [showAll, setShowAll] = useState(false);
|
||
const INITIAL = 5;
|
||
const displayed = showAll ? items : items.slice(0, INITIAL);
|
||
|
||
return (
|
||
<div className="bg-background-secondary rounded-lg p-6">
|
||
<h3 className="text-lg font-medium text-text-primary mb-4">
|
||
User-Agents ({items.length})
|
||
</h3>
|
||
<div className="space-y-3">
|
||
{displayed.map((item, index) => (
|
||
<div key={index} className="space-y-1">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="text-text-primary font-medium text-xs font-mono break-all leading-relaxed flex-1">
|
||
{item.value}
|
||
</div>
|
||
<div className="text-right shrink-0">
|
||
<div className="text-text-primary font-medium">{item.count}</div>
|
||
<div className="text-text-secondary text-xs">{item.percentage?.toFixed(1)}%</div>
|
||
</div>
|
||
</div>
|
||
<div className="w-full bg-background-card rounded-full h-2">
|
||
<div
|
||
className="h-2 rounded-full bg-threat-medium transition-all"
|
||
style={{ width: `${item.percentage}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{items.length > INITIAL && (
|
||
<button
|
||
onClick={() => setShowAll(v => !v)}
|
||
className="mt-4 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
|
||
>
|
||
{showAll ? '↑ Réduire' : `↓ Voir les ${items.length - INITIAL} autres`}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Composant AttributeSection
|
||
function AttributeSection({
|
||
title,
|
||
items,
|
||
getValue,
|
||
getLink,
|
||
onViewAll,
|
||
showViewAll = false,
|
||
viewAllLabel = 'Voir les IPs',
|
||
}: {
|
||
title: string;
|
||
items: AttributeValue[];
|
||
getValue: (item: AttributeValue) => string;
|
||
getLink: (item: AttributeValue) => string;
|
||
onViewAll?: (value: string, count: number) => void;
|
||
showViewAll?: boolean;
|
||
viewAllLabel?: string;
|
||
}) {
|
||
const displayItems = items.slice(0, 10);
|
||
|
||
return (
|
||
<div className="bg-background-secondary rounded-lg p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-lg font-medium text-text-primary">
|
||
{title} ({items.length})
|
||
</h3>
|
||
{showViewAll && items.length > 0 && (
|
||
<select
|
||
onChange={(e) => {
|
||
if (e.target.value && onViewAll) {
|
||
const item = items.find(i => i.value === e.target.value);
|
||
if (item) {
|
||
onViewAll(item.value, item.count);
|
||
}
|
||
}
|
||
}}
|
||
defaultValue=""
|
||
className="bg-background-card border border-background-card rounded-lg px-3 py-1 text-sm text-text-primary focus:outline-none focus:border-accent-primary"
|
||
>
|
||
<option value="">{viewAllLabel}...</option>
|
||
{displayItems.map((item, idx) => (
|
||
<option key={idx} value={item.value}>
|
||
{getValue(item).substring(0, 40)}{getValue(item).length > 40 ? '...' : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
<div className="space-y-3">
|
||
{displayItems.map((item, index) => (
|
||
<AttributeRow
|
||
key={index}
|
||
value={item}
|
||
getValue={getValue}
|
||
getLink={getLink}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{items.length > 10 && (
|
||
<p className="text-text-secondary text-sm mt-4 text-center">
|
||
... et {items.length - 10} autres (top 10 affiché)
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Composant AttributeRow
|
||
function AttributeRow({
|
||
value,
|
||
getValue,
|
||
getLink,
|
||
}: {
|
||
value: AttributeValue;
|
||
getValue: (item: AttributeValue) => string;
|
||
getLink: (item: AttributeValue) => string;
|
||
}) {
|
||
const percentage = value.percentage || 0;
|
||
|
||
return (
|
||
<div className="space-y-1">
|
||
<div className="flex items-center justify-between">
|
||
<Link
|
||
to={getLink(value)}
|
||
className="text-text-primary hover:text-accent-primary transition-colors font-medium break-all text-sm leading-relaxed flex-1"
|
||
>
|
||
{getValue(value)}
|
||
</Link>
|
||
<div className="text-right">
|
||
<div className="text-text-primary font-medium">{value.count}</div>
|
||
<div className="text-text-secondary text-xs">{percentage.toFixed(1)}%</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="w-full bg-background-card rounded-full h-2">
|
||
<div
|
||
className={`h-2 rounded-full transition-all ${getPercentageColor(percentage)}`}
|
||
style={{ width: `${percentage}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Helper pour la couleur de la barre
|
||
function getPercentageColor(percentage: number): string {
|
||
if (percentage >= 50) return 'bg-threat-critical';
|
||
if (percentage >= 25) return 'bg-threat-high';
|
||
if (percentage >= 10) return 'bg-threat-medium';
|
||
return 'bg-threat-low';
|
||
}
|