feat(web): Identities + Campaigns list pages + THREAT DATA nav group

Adds proper /identities and /campaigns list pages following the
Bounty/Attackers convention (page-header + page-title-group +
controls-row + logs-section + logs-table + EmptyState). Both pages
live-update via the existing identity / campaign SSE streams.

Sidebar: Attackers, Identities, Campaigns now group under a
THREAT DATA NavGroup, matching the SWARM grouping pattern.

CampaignDetail and IdentityDetail rewritten to use the house class
system (page-header / logs-section / chip / dim-chip) instead of
inline styles. The campaign chip on IdentityDetail navigates to
/campaigns/:uuid; both pages share a small fp-group helper for
fingerprint listings (added to Dashboard.css).
This commit is contained in:
2026-04-26 09:32:00 -04:00
parent 7fafdd66de
commit cc2deb73f7
7 changed files with 713 additions and 322 deletions

View File

@@ -1,24 +1,17 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Crosshair, Fingerprint, Globe, Radio } from '../icons';
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.
*
* The clusterer worker that populates these rows is a separate
* downstream effort; until it ships, /identities/* responses are
* empty and this page renders the not-found state. See
* development/IDENTITY_RESOLUTION.md.
*
* The page is intentionally narrow at v1: header (uuid + campaign
* link if assigned), aggregated stats (observation count, fingerprint
* counts), and a list of linked observations that link back to
* AttackerDetail. Bigger surfaces (intel summary, kd_digraph_simhash
* neighbors, federation gossip status) ship after the clusterer
* lands and there's data to render.
* 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 {
@@ -106,29 +99,22 @@ const IdentityDetail: React.FC = () => {
fetchObservations();
}, [id]);
// Live updates: when the clusterer fires an identity event that
// touches this identity (links a fresh observation, soft-merges,
// resurrects on unmerge), refetch both the row and the observations
// list so the page reflects current truth without a manual refresh.
useIdentityStream({
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);
};
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}`).then((res) => setIdentity(res.data)).catch(() => {});
api.get(`/identities/${id}/observations?limit=50&offset=0`)
.then((res) => {
setObservations(res.data.data ?? []);
@@ -141,24 +127,20 @@ const IdentityDetail: React.FC = () => {
if (loading) {
return (
<div className="dashboard">
<div style={{ textAlign: 'center', padding: '80px', opacity: 0.5, letterSpacing: '4px' }}>
LOADING IDENTITY
</div>
<div className="bounty-root">
<EmptyState icon={Fingerprint} title="LOADING IDENTITY…" />
</div>
);
}
if (error || !identity) {
return (
<div className="dashboard">
<button onClick={() => navigate('/attackers')} className="back-button">
<div className="bounty-root">
<button onClick={() => navigate('/identities')} className="back-button">
<ArrowLeft size={18} />
<span>BACK TO ATTACKERS</span>
<span>BACK TO IDENTITIES</span>
</button>
<div style={{ textAlign: 'center', padding: '80px', opacity: 0.5, letterSpacing: '4px' }}>
{error || 'IDENTITY NOT FOUND'}
</div>
<EmptyState icon={Fingerprint} title={error || 'IDENTITY NOT FOUND'} />
</div>
);
}
@@ -169,137 +151,125 @@ const IdentityDetail: React.FC = () => {
const c2List = safeParseJsonList(identity.c2_endpoints);
return (
<div className="dashboard">
<button onClick={() => navigate('/attackers')} className="back-button">
<div className="bounty-root">
<button onClick={() => navigate('/identities')} className="back-button">
<ArrowLeft size={18} />
<span>BACK TO ATTACKERS</span>
<span>BACK TO IDENTITIES</span>
</button>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Fingerprint size={32} className="violet-accent" />
<h1 className="matrix-text" style={{ fontSize: '1.4rem', letterSpacing: '2px' }}>
IDENTITY · {identity.uuid}
</h1>
{identity.campaign_id && (
<span
className="traversal-badge"
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>
)}
{identity.merged_into_uuid && (
<span
className="traversal-badge"
style={{ fontSize: '0.8rem', cursor: 'pointer', letterSpacing: '2px', opacity: 0.7 }}
title="This identity was soft-merged into another. Click to view the canonical winner."
onClick={() => navigate(`/identities/${identity.merged_into_uuid}`)}
>
MERGED INTO {identity.merged_into_uuid.slice(0, 8)}
</span>
)}
</div>
{/* Stats row */}
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
<div className="stat-card" title="Live count of attacker observations FK'd to this identity">
<div className="stat-value matrix-text">{identity.observation_count_live}</div>
<div className="stat-label">OBSERVATIONS</div>
</div>
<div className="stat-card" title="Distinct JA3 TLS fingerprints across this identity's tooling">
<div className="stat-value violet-accent">{ja3List.length}</div>
<div className="stat-label">JA3</div>
</div>
<div className="stat-card" title="Distinct HASSH SSH-client 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 (Hamming-comparable)">
<div className="stat-value matrix-text">{payloadList.length}</div>
<div className="stat-label">PAYLOADS</div>
</div>
<div className="stat-card" title="C2 callback endpoints observed">
<div className="stat-value matrix-text">{c2List.length}</div>
<div className="stat-label">C2 ENDPOINTS</div>
</div>
</div>
{/* Confidence + schema version, only show if populated */}
{(identity.confidence !== null || identity.schema_version > 1) && (
<div style={{ display: 'flex', gap: '24px', padding: '12px 0', opacity: 0.7, fontSize: '0.85rem' }}>
{identity.confidence !== null && (
<span title="Identity-cohesion score from the clusterer (01)">
CONFIDENCE · {identity.confidence.toFixed(3)}
</span>
)}
<span title="Federation gossip schema version">
SCHEMA · v{identity.schema_version}
</span>
</div>
)}
{/* Fingerprint detail rows */}
{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} />
)}
{/* Observations table */}
<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' }}>
OBSERVATIONS · {observationTotal}
</h2>
</div>
{observations.length === 0 ? (
<div style={{ padding: '24px', opacity: 0.5, fontFamily: 'var(--font-mono)' }}>
No observations linked yet. The clusterer assigns observations
asynchronously; they should appear shortly after the next
clusterer pass.
<div className="page-header">
<div className="page-title-group">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<Fingerprint size={22} className="violet-accent" />
<h1>IDENTITY · {identity.uuid.slice(0, 12)}</h1>
{identity.campaign_id && (
<span
className="chip violet"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/campaigns/${identity.campaign_id}`)}
title="Campaign assignment from the campaign clusterer"
>
CAMPAIGN · {identity.campaign_id.slice(0, 8)}
</span>
)}
{identity.merged_into_uuid && (
<span
className="chip dim-chip"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/identities/${identity.merged_into_uuid}`)}
title="Soft-merged. Click to view canonical winner."
>
MERGED {identity.merged_into_uuid.slice(0, 8)}
</span>
)}
</div>
) : (
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th>IP</th>
<th>FIRST SEEN</th>
<th>LAST SEEN</th>
<th style={{ textAlign: 'right' }}>EVENTS</th>
</tr>
</thead>
<tbody>
{observations.map((obs) => (
<tr
key={obs.uuid}
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/attackers/${obs.uuid}`)}
>
<td>{obs.ip}</td>
<td style={{ opacity: 0.7 }}>{obs.first_seen}</td>
<td style={{ opacity: 0.7 }}>{obs.last_seen}</td>
<td style={{ textAlign: 'right' }}>{obs.event_count}</td>
<span className="page-sub">
{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}
</span>
</div>
</div>
{(ja3List.length > 0 || hasshList.length > 0 || c2List.length > 0) && (
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Fingerprint size={14} />
<span>FINGERPRINTS</span>
</div>
</div>
<div className="logs-table-container" style={{ padding: 12 }}>
{ja3List.length > 0 && (
<FingerprintGroup icon={<Globe size={14} />} label="JA3" items={ja3List} />
)}
{hasshList.length > 0 && (
<FingerprintGroup icon={<Globe size={14} />} label="HASSH" items={hasshList} />
)}
{c2List.length > 0 && (
<FingerprintGroup icon={<Radio size={14} />} label="C2 ENDPOINTS" items={c2List} />
)}
</div>
</div>
)}
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Filter size={14} />
<span>{observationTotal} OBSERVATIONS LINKED</span>
</div>
</div>
<div className="logs-table-container">
{observations.length === 0 ? (
<EmptyState
icon={Crosshair}
title="NO OBSERVATIONS LINKED YET"
hint="the clusterer assigns observations asynchronously"
/>
) : (
<table className="logs-table">
<thead>
<tr>
<th>IP</th>
<th>FIRST SEEN</th>
<th>LAST SEEN</th>
<th style={{ textAlign: 'right' }}>EVENTS</th>
</tr>
))}
</tbody>
</table>
)}
</thead>
<tbody>
{observations.map((obs) => (
<tr
key={obs.uuid}
className="clickable"
onClick={() => navigate(`/attackers/${obs.uuid}`)}
>
<td className="matrix-text">{obs.ip}</td>
<td className="dim">{obs.first_seen}</td>
<td className="dim">{obs.last_seen}</td>
<td className="matrix-text" style={{ textAlign: 'right' }}>
{obs.event_count}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{identity.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 className="logs-section">
<div className="section-header">
<div className="section-title">
<span>ANALYST NOTES</span>
</div>
</div>
<div style={{ fontFamily: 'var(--font-mono)', whiteSpace: 'pre-wrap' }}>
<div className="logs-table-container" style={{ padding: 12, fontFamily: 'var(--font-mono)', whiteSpace: 'pre-wrap' }}>
{identity.notes}
</div>
</div>
@@ -308,32 +278,19 @@ const IdentityDetail: React.FC = () => {
);
};
const FingerprintList: React.FC<{
const FingerprintGroup: 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' }}>
<div className="fp-group">
<div className="fp-group-label">
{icon}
<span className="matrix-text" style={{ fontSize: '0.85rem', letterSpacing: '2px' }}>
{label}
</span>
<span>{label}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
<div className="fp-group-items">
{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>
<span key={v} className="chip dim-chip">{v}</span>
))}
</div>
</div>