feat(web): live identity-resolution updates via SSE

useIdentityStream hook mirrors useTopologyStream — opens an
EventSource against /api/v1/identities/events with the JWT in
?token=, dispatches the five named events (snapshot, formed,
observation.linked, merged, unmerged) to the consumer, reconnects
3s after any error.

AttackerDetail subscribes whenever it has an attacker id loaded.
On any event whose payload references this observation's uuid OR
the attacker's current identity_id, refetch /attackers/{id} so the
IDENTITY badge appears (or follows through merges / unmerges) live
without a tab refocus.

IdentityDetail subscribes whenever it has an identity id loaded.
On any event whose payload references this identity_id (formed for
it, merge winner / loser, unmerge resurrected / former-winner), it
refetches both the identity row and its observations list.

Both consumers filter inside onEvent — the hook itself is dumb glue
and stays unaware of which uuids any given component cares about.
This commit is contained in:
2026-04-26 08:38:27 -04:00
parent 97aa57faed
commit 059d1dba75
3 changed files with 179 additions and 0 deletions

View File

@@ -2,6 +2,7 @@ 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 { useIdentityStream } from './useIdentityStream';
import './Dashboard.css';
/*
@@ -105,6 +106,39 @@ 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);
};
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 (
<div className="dashboard">