feat(web-ui): IdentityDetail page + conditional Identity badge on AttackerDetail

Third of the five-step identity-resolution substrate. Frontend hooks
into the empty /api/v1/identities/* surface from commit 2; renders
nothing visible when identity_id is null (which is the universal state
until the clusterer ships).

* decnet_web/src/components/IdentityDetail.tsx — new page. Header with
  uuid + optional CAMPAIGN / MERGED-INTO badges, stats row
  (observations / JA3 / HASSH / payloads / C2), fingerprint tag lists
  parsed from the JSON-in-TEXT columns, observations table that links
  back to AttackerDetail, conditional analyst-notes panel.
* decnet_web/src/components/AttackerDetail.tsx — IDENTITY badge
  inserted in the header row alongside TRAVERSAL. Clicking navigates
  to /identities/<uuid>. AttackerData interface gains the optional
  identity_id field.
* decnet_web/src/App.tsx — /identities/:id route + lazy-loaded chunk.

Verified by `tsc --noEmit` (clean) and `vite build` (clean — produces
IdentityDetail-*.js as its own lazy chunk). The repo has no JS test
harness; build + type-check are the gate.
This commit is contained in:
2026-04-26 07:12:37 -04:00
parent dc3d08dd41
commit 448212ebcd
3 changed files with 332 additions and 0 deletions

View File

@@ -46,6 +46,11 @@ interface AttackerBehavior {
interface AttackerData {
uuid: string;
ip: string;
// Resolved identity FK. NULL while the clusterer hasn't run on this
// observation yet, or hasn't seen enough stable signal (JA3, HASSH,
// payload hash, C2 callback) to claim a same-hands match. See
// development/IDENTITY_RESOLUTION.md.
identity_id?: string | null;
first_seen: string;
last_seen: string;
event_count: number;
@@ -1405,6 +1410,24 @@ const AttackerDetail: React.FC = () => {
{attacker.is_traversal && (
<span className="traversal-badge" style={{ fontSize: '0.8rem' }}>TRAVERSAL</span>
)}
{/* Conditional Identity badge — surfaces only when the clusterer
has linked this observation to a resolved actor identity.
Zero behavior change when identity_id is null (which is
uniformly true until the clusterer ships). */}
{attacker.identity_id && (
<span
className="traversal-badge"
style={{
fontSize: '0.8rem',
cursor: 'pointer',
letterSpacing: '2px',
}}
title="Resolved identity — click to view all observations linked to this actor"
onClick={() => navigate(`/identities/${attacker.identity_id}`)}
>
IDENTITY · {attacker.identity_id.slice(0, 8)}
</span>
)}
</div>
{/* Stats Row */}