feat(web): read-only campaigns API + SSE + frontend

API: /api/v1/campaigns (paginated list), /api/v1/campaigns/{uuid}
(soft-merge chain follow), /api/v1/campaigns/{uuid}/identities
(member identities), and /api/v1/campaigns/events (SSE under
campaign.> + JWT-via-?token=, snapshot-on-connect). Mirror of the
identity router; same auth, same shape, same OpenAPI tags pattern.

Frontend: CampaignDetail.tsx page (same visual vocabulary as
IdentityDetail), useCampaignStream hook (mirror of
useIdentityStream), /campaigns/:id route, IdentityDetail's
CAMPAIGN badge becomes clickable and navigates to the campaign.
useIdentityStream now listens for identity.campaign.assigned so
the badge appears live without a manual refresh.
This commit is contained in:
2026-04-26 09:20:17 -04:00
parent 75af00c9c8
commit d531cea536
14 changed files with 1035 additions and 3 deletions

View File

@@ -20,6 +20,7 @@ const Webhooks = lazy(() => import('./components/Webhooks'));
const Attackers = lazy(() => import('./components/Attackers'));
const AttackerDetail = lazy(() => import('./components/AttackerDetail'));
const IdentityDetail = lazy(() => import('./components/IdentityDetail'));
const CampaignDetail = lazy(() => import('./components/CampaignDetail'));
const Config = lazy(() => import('./components/Config'));
const Bounty = lazy(() => import('./components/Bounty'));
const Credentials = lazy(() => import('./components/Credentials'));
@@ -115,6 +116,7 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
<Route path="/attackers" element={<Attackers />} />
<Route path="/attackers/:id" element={<AttackerDetail />} />
<Route path="/identities/:id" element={<IdentityDetail />} />
<Route path="/campaigns/:id" element={<CampaignDetail />} />
<Route path="/config" element={<Config />} />
<Route path="/swarm-updates" element={<RemoteUpdates />} />
<Route path="/swarm/hosts" element={<SwarmHosts />} />

View File

@@ -0,0 +1,315 @@
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<CampaignData | null>(null);
const [identities, setIdentities] = useState<IdentityRow[]>([]);
const [identityTotal, setIdentityTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string>();
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 (
<div className="dashboard">
<div style={{ textAlign: 'center', padding: '80px', opacity: 0.5, letterSpacing: '4px' }}>
LOADING CAMPAIGN
</div>
</div>
);
}
if (error || !campaign) {
return (
<div className="dashboard">
<button onClick={() => navigate('/attackers')} className="back-button">
<ArrowLeft size={18} />
<span>BACK TO ATTACKERS</span>
</button>
<div style={{ textAlign: 'center', padding: '80px', opacity: 0.5, letterSpacing: '4px' }}>
{error || 'CAMPAIGN NOT FOUND'}
</div>
</div>
);
}
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 (
<div className="dashboard">
<button onClick={() => navigate('/attackers')} className="back-button">
<ArrowLeft size={18} />
<span>BACK TO ATTACKERS</span>
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Fingerprint size={32} className="violet-accent" />
<h1 className="matrix-text" style={{ fontSize: '1.4rem', letterSpacing: '2px' }}>
CAMPAIGN · {campaign.uuid}
</h1>
{campaign.merged_into_uuid && (
<span
className="traversal-badge"
style={{ fontSize: '0.8rem', cursor: 'pointer', letterSpacing: '2px', opacity: 0.7 }}
title="This campaign was soft-merged into another. Click to view the canonical winner."
onClick={() => navigate(`/campaigns/${campaign.merged_into_uuid}`)}
>
MERGED INTO {campaign.merged_into_uuid.slice(0, 8)}
</span>
)}
</div>
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
<div className="stat-card" title="Live count of identities FK'd to this campaign">
<div className="stat-value matrix-text">{campaign.identity_count_live}</div>
<div className="stat-label">IDENTITIES</div>
</div>
<div className="stat-card" title="Distinct JA3 fingerprints across member identities">
<div className="stat-value violet-accent">{ja3List.length}</div>
<div className="stat-label">JA3</div>
</div>
<div className="stat-card" title="Distinct HASSH fingerprints">
<div className="stat-value violet-accent">{hasshList.length}</div>
<div className="stat-label">HASSH</div>
</div>
<div className="stat-card" title="Distinct payload SimHashes aggregated across identities">
<div className="stat-value matrix-text">{payloadList.length}</div>
<div className="stat-label">PAYLOADS</div>
</div>
<div className="stat-card" title="C2 callback endpoints aggregated across identities">
<div className="stat-value matrix-text">{c2List.length}</div>
<div className="stat-label">C2 ENDPOINTS</div>
</div>
</div>
{(campaign.confidence !== null || campaign.schema_version > 1) && (
<div style={{ display: 'flex', gap: '24px', padding: '12px 0', opacity: 0.7, fontSize: '0.85rem' }}>
{campaign.confidence !== null && (
<span title="Campaign-cohesion score from the clusterer (01)">
CONFIDENCE · {campaign.confidence.toFixed(3)}
</span>
)}
<span title="Federation gossip schema version">
SCHEMA · v{campaign.schema_version}
</span>
</div>
)}
{ja3List.length > 0 && (
<FingerprintList icon={<Globe size={18} />} label="JA3" items={ja3List} />
)}
{hasshList.length > 0 && (
<FingerprintList icon={<Globe size={18} />} label="HASSH" items={hasshList} />
)}
{c2List.length > 0 && (
<FingerprintList icon={<Radio size={18} />} label="C2 ENDPOINTS" items={c2List} />
)}
<div style={{ marginTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<Crosshair size={20} className="violet-accent" />
<h2 className="matrix-text" style={{ fontSize: '1.0rem', letterSpacing: '2px' }}>
IDENTITIES · {identityTotal}
</h2>
</div>
{identities.length === 0 ? (
<div style={{ padding: '24px', opacity: 0.5, fontFamily: 'var(--font-mono)' }}>
No identities linked yet. The campaign clusterer assigns
identities asynchronously; they should appear shortly after
the next clusterer pass.
</div>
) : (
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th>IDENTITY</th>
<th>FIRST SEEN</th>
<th>LAST SEEN</th>
<th style={{ textAlign: 'right' }}>OBSERVATIONS</th>
</tr>
</thead>
<tbody>
{identities.map((ident) => (
<tr
key={ident.uuid}
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/identities/${ident.uuid}`)}
>
<td>{ident.uuid.slice(0, 12)}</td>
<td style={{ opacity: 0.7 }}>{ident.first_seen_at ?? '—'}</td>
<td style={{ opacity: 0.7 }}>{ident.last_seen_at ?? '—'}</td>
<td style={{ textAlign: 'right' }}>{ident.observation_count}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{campaign.notes && (
<div style={{ marginTop: '24px', padding: '12px', borderLeft: '2px solid var(--violet)', opacity: 0.85 }}>
<div style={{ fontSize: '0.75rem', opacity: 0.7, letterSpacing: '2px', marginBottom: '4px' }}>
ANALYST NOTES
</div>
<div style={{ fontFamily: 'var(--font-mono)', whiteSpace: 'pre-wrap' }}>
{campaign.notes}
</div>
</div>
)}
</div>
);
};
const FingerprintList: React.FC<{
icon: React.ReactNode;
label: string;
items: string[];
}> = ({ icon, label, items }) => (
<div style={{ marginTop: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
{icon}
<span className="matrix-text" style={{ fontSize: '0.85rem', letterSpacing: '2px' }}>
{label}
</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{items.map((v) => (
<code
key={v}
style={{
fontSize: '0.75rem',
padding: '4px 8px',
background: 'var(--card-bg)',
border: '1px solid var(--border-color)',
borderRadius: '2px',
}}
>
{v}
</code>
))}
</div>
</div>
);
export default CampaignDetail;

View File

@@ -184,8 +184,9 @@ const IdentityDetail: React.FC = () => {
{identity.campaign_id && (
<span
className="traversal-badge"
style={{ fontSize: '0.8rem', cursor: 'default', letterSpacing: '2px' }}
title="Campaign assignment from the campaign clusterer"
style={{ fontSize: '0.8rem', cursor: 'pointer', letterSpacing: '2px' }}
title="Campaign assignment from the campaign clusterer. Click to view campaign."
onClick={() => navigate(`/campaigns/${identity.campaign_id}`)}
>
CAMPAIGN · {identity.campaign_id.slice(0, 8)}
</span>

View File

@@ -0,0 +1,100 @@
/**
* Campaign-clustering event stream — opens an SSE connection to
* `/campaigns/events` and dispatches typed events to the caller.
*
* Mirror of `useIdentityStream` for the layer above. CampaignDetail
* subscribes to refresh its own row + linked-identity list when
* `campaign.identity.assigned` / `campaign.merged` / `campaign.unmerged`
* fires.
*/
import { useEffect, useRef } from 'react';
export type CampaignStreamEventName =
| 'snapshot'
| 'formed'
| 'identity.assigned'
| 'merged'
| 'unmerged';
export interface CampaignStreamEvent {
name: CampaignStreamEventName | string;
topic?: string;
type?: string;
ts?: string;
payload: Record<string, unknown>;
}
export interface UseCampaignStreamOptions {
enabled: boolean;
onEvent: (event: CampaignStreamEvent) => void;
onError?: () => void;
}
const NAMED_EVENTS: CampaignStreamEventName[] = [
'snapshot',
'formed',
'identity.assigned',
'merged',
'unmerged',
];
export function useCampaignStream({
enabled,
onEvent,
onError,
}: UseCampaignStreamOptions): void {
const esRef = useRef<EventSource | null>(null);
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onEventRef = useRef(onEvent);
const onErrorRef = useRef(onError);
useEffect(() => { onEventRef.current = onEvent; }, [onEvent]);
useEffect(() => { onErrorRef.current = onError; }, [onError]);
useEffect(() => {
if (!enabled) return;
const connect = () => {
if (esRef.current) esRef.current.close();
const token = localStorage.getItem('token') ?? '';
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
const url = `${baseUrl}/campaigns/events?token=${encodeURIComponent(token)}`;
const es = new EventSource(url);
esRef.current = es;
const dispatch = (name: string) => (event: MessageEvent) => {
try {
const parsed = JSON.parse(event.data) as Partial<CampaignStreamEvent>;
onEventRef.current({
name,
topic: parsed.topic,
type: parsed.type,
ts: parsed.ts,
payload: (parsed.payload ?? {}) as Record<string, unknown>,
});
} catch (err) {
console.error('useCampaignStream: parse failed', err);
}
};
for (const name of NAMED_EVENTS) {
es.addEventListener(name, dispatch(name) as EventListener);
}
es.onerror = () => {
es.close();
esRef.current = null;
onErrorRef.current?.();
reconnectRef.current = setTimeout(connect, 3000);
};
};
connect();
return () => {
if (reconnectRef.current) clearTimeout(reconnectRef.current);
if (esRef.current) esRef.current.close();
esRef.current = null;
};
}, [enabled]);
}

View File

@@ -25,7 +25,8 @@ export type IdentityStreamEventName =
| 'formed'
| 'observation.linked'
| 'merged'
| 'unmerged';
| 'unmerged'
| 'campaign.assigned';
export interface IdentityStreamEvent {
name: IdentityStreamEventName | string;
@@ -47,6 +48,7 @@ const NAMED_EVENTS: IdentityStreamEventName[] = [
'observation.linked',
'merged',
'unmerged',
'campaign.assigned',
];
export function useIdentityStream({