import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { ArrowLeft, Crosshair, Filter, Fingerprint, Globe, Radio } from '../icons'; import api from '../utils/api'; import EmptyState from './EmptyState/EmptyState'; import { useIdentityStream } from './useIdentityStream'; import './Dashboard.css'; /* * IdentityDetail — read-only view of a resolved attacker identity. * * Header (page-header), aggregated stats in the sub-line, fingerprint * groups in their own section, observations in a logs-section table. * Same vocabulary as CampaignDetail one layer up. */ interface IdentityData { uuid: string; schema_version: number; campaign_id: string | null; first_seen_at: string | null; last_seen_at: string | null; created_at: string; updated_at: string; confidence: number | null; observation_count: number; observation_count_live: number; ja3_hashes: string | null; hassh_hashes: string | null; payload_simhashes: string | null; c2_endpoints: string | null; kd_digraph_simhash: string | null; merged_into_uuid: string | null; notes: string | null; } interface ObservationRow { uuid: string; ip: string; first_seen: string; last_seen: string; event_count: number; asn?: number | null; country_code?: string | null; } const safeParseJsonList = (raw: string | null): string[] => { if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { return []; } }; const IdentityDetail: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [identity, setIdentity] = useState(null); const [observations, setObservations] = useState([]); const [observationTotal, setObservationTotal] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { if (!id) return; const fetchIdentity = async () => { setLoading(true); try { const res = await api.get(`/identities/${id}`); setIdentity(res.data); setError(null); } catch (err: any) { if (err.response?.status === 404) { setError('IDENTITY NOT FOUND'); } else { setError('FAILED TO LOAD IDENTITY'); } } finally { setLoading(false); } }; fetchIdentity(); }, [id]); useEffect(() => { if (!id) return; const fetchObservations = async () => { try { const res = await api.get(`/identities/${id}/observations?limit=50&offset=0`); setObservations(res.data.data ?? []); setObservationTotal(res.data.total ?? 0); } catch { setObservations([]); setObservationTotal(0); } }; fetchObservations(); }, [id]); useIdentityStream({ enabled: !!id, onEvent: (ev) => { if (!id) return; const refs = new Set(); const addUuid = (v: unknown) => { if (typeof v === 'string') refs.add(v); }; const payload = ev.payload || {}; addUuid(payload.identity_uuid); addUuid(payload.winner_uuid); addUuid(payload.loser_uuid); addUuid(payload.resurrected_uuid); addUuid(payload.former_winner_uuid); if (refs.has(id)) { api.get(`/identities/${id}`).then((res) => setIdentity(res.data)).catch(() => {}); api.get(`/identities/${id}/observations?limit=50&offset=0`) .then((res) => { setObservations(res.data.data ?? []); setObservationTotal(res.data.total ?? 0); }) .catch(() => {}); } }, }); if (loading) { return (
); } if (error || !identity) { return (
); } const ja3List = safeParseJsonList(identity.ja3_hashes); const hasshList = safeParseJsonList(identity.hassh_hashes); const payloadList = safeParseJsonList(identity.payload_simhashes); const c2List = safeParseJsonList(identity.c2_endpoints); return (

IDENTITY · {identity.uuid.slice(0, 12)}…

{identity.campaign_id && ( navigate(`/campaigns/${identity.campaign_id}`)} title="Campaign assignment from the campaign clusterer" > CAMPAIGN · {identity.campaign_id.slice(0, 8)}… )} {identity.merged_into_uuid && ( navigate(`/identities/${identity.merged_into_uuid}`)} title="Soft-merged. Click to view canonical winner." > MERGED → {identity.merged_into_uuid.slice(0, 8)}… )}
{identity.observation_count_live} OBSERVATIONS · {' '}{ja3List.length} JA3 · {hasshList.length} HASSH · {' '}{payloadList.length} PAYLOAD · {c2List.length} C2 {identity.confidence !== null && ( <> · CONFIDENCE {identity.confidence.toFixed(3)} )} {' '}· SCHEMA v{identity.schema_version}
{(ja3List.length > 0 || hasshList.length > 0 || c2List.length > 0) && (
FINGERPRINTS
{ja3List.length > 0 && ( } label="JA3" items={ja3List} /> )} {hasshList.length > 0 && ( } label="HASSH" items={hasshList} /> )} {c2List.length > 0 && ( } label="C2 ENDPOINTS" items={c2List} /> )}
)}
{observationTotal} OBSERVATIONS LINKED
{observations.length === 0 ? ( ) : ( {observations.map((obs) => ( navigate(`/attackers/${obs.uuid}`)} > ))}
IP FIRST SEEN LAST SEEN EVENTS
{obs.ip} {obs.first_seen} {obs.last_seen} {obs.event_count}
)}
{identity.notes && (
ANALYST NOTES
{identity.notes}
)}
); }; const FingerprintGroup: React.FC<{ icon: React.ReactNode; label: string; items: string[]; }> = ({ icon, label, items }) => (
{icon} {label}
{items.map((v) => ( {v} ))}
); export default IdentityDetail;