import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { ArrowLeft, Crosshair, Fingerprint, Globe, Radio } from '../icons'; import api from '../utils/api'; import { useCampaignStream } from './useCampaignStream'; import './Dashboard.css'; /* * CampaignDetail — read-only view of a campaign-clustered operation. * * The layer above identity resolution. Member identities are visible * here as rows that link back to IdentityDetail. Same visual vocabulary * as IdentityDetail by design — the substrate (soft merges, schema * version, JSON fingerprint summaries, live SSE updates) is identical * one layer up. */ interface CampaignData { uuid: string; schema_version: number; first_seen_at: string | null; last_seen_at: string | null; created_at: string; updated_at: string; confidence: number | null; identity_count: number; identity_count_live: number; ja3_hashes: string | null; hassh_hashes: string | null; payload_simhashes: string | null; c2_endpoints: string | null; merged_into_uuid: string | null; notes: string | null; } interface IdentityRow { uuid: string; first_seen_at: string | null; last_seen_at: string | null; observation_count: number; campaign_id: string | null; merged_into_uuid: 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 CampaignDetail: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [campaign, setCampaign] = useState(null); const [identities, setIdentities] = useState([]); const [identityTotal, setIdentityTotal] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { if (!id) return; const fetchCampaign = async () => { setLoading(true); try { const res = await api.get(`/campaigns/${id}`); setCampaign(res.data); setError(null); } catch (err: any) { if (err.response?.status === 404) { setError('CAMPAIGN NOT FOUND'); } else { setError('FAILED TO LOAD CAMPAIGN'); } } finally { setLoading(false); } }; fetchCampaign(); }, [id]); useEffect(() => { if (!id) return; const fetchIdentities = async () => { try { const res = await api.get(`/campaigns/${id}/identities?limit=50&offset=0`); setIdentities(res.data.data ?? []); setIdentityTotal(res.data.total ?? 0); } catch { setIdentities([]); setIdentityTotal(0); } }; fetchIdentities(); }, [id]); // Live updates: refetch when a campaign event references this uuid. useCampaignStream({ enabled: !!id, onEvent: (ev) => { if (!id) return; const payload = ev.payload || {}; const refs = new Set(); const addUuid = (v: unknown) => { if (typeof v === 'string') refs.add(v); }; addUuid(payload.campaign_uuid); addUuid(payload.winner_uuid); addUuid(payload.loser_uuid); addUuid(payload.resurrected_uuid); addUuid(payload.former_winner_uuid); if (refs.has(id)) { api.get(`/campaigns/${id}`) .then((res) => setCampaign(res.data)) .catch(() => {}); api.get(`/campaigns/${id}/identities?limit=50&offset=0`) .then((res) => { setIdentities(res.data.data ?? []); setIdentityTotal(res.data.total ?? 0); }) .catch(() => {}); } }, }); if (loading) { return (
LOADING CAMPAIGN…
); } if (error || !campaign) { return (
{error || 'CAMPAIGN NOT FOUND'}
); } const ja3List = safeParseJsonList(campaign.ja3_hashes); const hasshList = safeParseJsonList(campaign.hassh_hashes); const payloadList = safeParseJsonList(campaign.payload_simhashes); const c2List = safeParseJsonList(campaign.c2_endpoints); return (

CAMPAIGN · {campaign.uuid}

{campaign.merged_into_uuid && ( navigate(`/campaigns/${campaign.merged_into_uuid}`)} > MERGED INTO {campaign.merged_into_uuid.slice(0, 8)} )}
{campaign.identity_count_live}
IDENTITIES
{ja3List.length}
JA3
{hasshList.length}
HASSH
{payloadList.length}
PAYLOADS
{c2List.length}
C2 ENDPOINTS
{(campaign.confidence !== null || campaign.schema_version > 1) && (
{campaign.confidence !== null && ( CONFIDENCE · {campaign.confidence.toFixed(3)} )} SCHEMA · v{campaign.schema_version}
)} {ja3List.length > 0 && ( } label="JA3" items={ja3List} /> )} {hasshList.length > 0 && ( } label="HASSH" items={hasshList} /> )} {c2List.length > 0 && ( } label="C2 ENDPOINTS" items={c2List} /> )}

IDENTITIES · {identityTotal}

{identities.length === 0 ? (
No identities linked yet. The campaign clusterer assigns identities asynchronously; they should appear shortly after the next clusterer pass.
) : ( {identities.map((ident) => ( navigate(`/identities/${ident.uuid}`)} > ))}
IDENTITY FIRST SEEN LAST SEEN OBSERVATIONS
{ident.uuid.slice(0, 12)}… {ident.first_seen_at ?? '—'} {ident.last_seen_at ?? '—'} {ident.observation_count}
)}
{campaign.notes && (
ANALYST NOTES
{campaign.notes}
)}
); }; const FingerprintList: React.FC<{ icon: React.ReactNode; label: string; items: string[]; }> = ({ icon, label, items }) => (
{icon} {label}
{items.map((v) => ( {v} ))}
); export default CampaignDetail;