Per-primitive state badge rendered next to each value in the
Behavioural Primitives panel. Five-state vocabulary, frozen, mirrors
decnet/correlation/attribution/aggregate.py:
* STABLE — green, low-key
* DRIFTING — amber, draws the eye
* CONFLICTED — red
* MULTI-ACTOR — purple, loudest (cross-primitive escalation lives
in attribution.multi_actor_suspected, not the
per-primitive badge)
* UNKNOWN — neutral border, no fill
Wiring:
* GET /api/v1/attackers/{id}/attribution on mount + on id change.
Failures swallowed silently (the worker may be off in dev).
* useAttackerStream gains attribution.state_changed +
attribution.multi_actor_suspected named events. The state-changed
handler merges by primitive and locks last_change_ts when the
state did not actually flip (defensive — backend already gates
these on transition, but a future relaxation shouldn't lie about
"stable since X" on the badge tooltip).
* multi_actor_suspected is wired but unused by the badges; the
per-primitive multi_actor signal already shows on each contributing
primitive. The handler is in place so a future "two operators
detected" banner has a live source.
Vitest: 4 new tests (badge renders only for mapped primitives, all
five states render with distinct labels, no badge when prop omitted)
on top of the existing 4. 7 of 7 pass; tsc + vite build clean.
209 lines
7.1 KiB
TypeScript
209 lines
7.1 KiB
TypeScript
/**
|
|
* Per-attacker behavioural event stream — opens an SSE connection to
|
|
* `/attackers/{uuid}/events` and dispatches typed events to the caller.
|
|
*
|
|
* Mirrors `useIdentityStream` (reconnect on error after 3s, callbacks
|
|
* stashed in refs so the connection isn't torn down on every consumer
|
|
* rerender). Unlike the identity stream's broad firehose, this hook
|
|
* is scoped to ONE attacker — the backend per-attacker filter keys on
|
|
* `payload.attacker_uuid` so consumers only receive their attacker's
|
|
* events.
|
|
*
|
|
* Event names emitted by the backend (`_sse_name_for` in
|
|
* `decnet/web/router/attackers/api_events.py`):
|
|
*
|
|
* * `snapshot` — one-shot, fires immediately on connect
|
|
* with `{attacker_uuid, observations: [...]}`.
|
|
* * `observation` — every `attacker.observation.<primitive>`
|
|
* event collapses to this single name; the
|
|
* primitive rides in `payload.primitive`.
|
|
* * `fingerprint.rotated` — `attacker.fingerprint_rotated`.
|
|
* * `attacker.scored` — score-threshold crossings.
|
|
* * `attribution.state_changed` — per-(identity, primitive)
|
|
* state-machine transition (Phase 4 of the
|
|
* attribution engine: stable / drifting /
|
|
* conflicted / multi_actor / unknown).
|
|
* Backend filters on `payload.identity_uuid`
|
|
* matching the attacker's resolved identity.
|
|
* * `attribution.multi_actor_suspected` — cross-primitive correlator
|
|
* output (Phase 5): >= 2 primitives flagged
|
|
* multi_actor on the same identity.
|
|
*/
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
export interface ObservationFrame {
|
|
primitive: string;
|
|
value: unknown;
|
|
confidence: number;
|
|
ts?: number;
|
|
source?: string;
|
|
attacker_uuid?: string;
|
|
}
|
|
|
|
export interface SnapshotFrame {
|
|
attacker_uuid: string;
|
|
observations: ObservationFrame[];
|
|
}
|
|
|
|
export type AttributionState =
|
|
| 'unknown'
|
|
| 'stable'
|
|
| 'drifting'
|
|
| 'conflicted'
|
|
| 'multi_actor';
|
|
|
|
export interface AttributionStateChangedFrame {
|
|
identity_uuid: string;
|
|
primitive: string;
|
|
old_state: AttributionState | null;
|
|
new_state: AttributionState;
|
|
current_value: unknown;
|
|
confidence: number;
|
|
observation_count: number;
|
|
ts: number;
|
|
}
|
|
|
|
export interface AttributionMultiActorFrame {
|
|
identity_uuid: string;
|
|
primitives: string[];
|
|
evidence_summary: string;
|
|
confidence: number;
|
|
ts: number;
|
|
}
|
|
|
|
export type AttackerStreamEventName =
|
|
| 'snapshot'
|
|
| 'observation'
|
|
| 'fingerprint.rotated'
|
|
| 'attacker.scored'
|
|
| 'attribution.state_changed'
|
|
| 'attribution.multi_actor_suspected';
|
|
|
|
export interface AttackerStreamEvent {
|
|
name: AttackerStreamEventName | string;
|
|
topic?: string;
|
|
type?: string;
|
|
ts?: string;
|
|
payload: Record<string, unknown>;
|
|
}
|
|
|
|
export interface UseAttackerStreamOptions {
|
|
attackerUuid: string;
|
|
enabled: boolean;
|
|
onSnapshot?: (data: SnapshotFrame) => void;
|
|
onObservation?: (data: ObservationFrame) => void;
|
|
onFingerprintRotated?: (data: Record<string, unknown>) => void;
|
|
onScored?: (data: Record<string, unknown>) => void;
|
|
onAttributionStateChanged?: (data: AttributionStateChangedFrame) => void;
|
|
onMultiActorSuspected?: (data: AttributionMultiActorFrame) => void;
|
|
onError?: () => void;
|
|
}
|
|
|
|
const NAMED_EVENTS: AttackerStreamEventName[] = [
|
|
'snapshot',
|
|
'observation',
|
|
'fingerprint.rotated',
|
|
'attacker.scored',
|
|
'attribution.state_changed',
|
|
'attribution.multi_actor_suspected',
|
|
];
|
|
|
|
const RECONNECT_MS = 3000;
|
|
|
|
export function useAttackerStream({
|
|
attackerUuid,
|
|
enabled,
|
|
onSnapshot,
|
|
onObservation,
|
|
onFingerprintRotated,
|
|
onScored,
|
|
onAttributionStateChanged,
|
|
onMultiActorSuspected,
|
|
onError,
|
|
}: UseAttackerStreamOptions): void {
|
|
const esRef = useRef<EventSource | null>(null);
|
|
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const onSnapshotRef = useRef(onSnapshot);
|
|
const onObservationRef = useRef(onObservation);
|
|
const onFingerprintRotatedRef = useRef(onFingerprintRotated);
|
|
const onScoredRef = useRef(onScored);
|
|
const onAttributionStateChangedRef = useRef(onAttributionStateChanged);
|
|
const onMultiActorSuspectedRef = useRef(onMultiActorSuspected);
|
|
const onErrorRef = useRef(onError);
|
|
useEffect(() => { onSnapshotRef.current = onSnapshot; }, [onSnapshot]);
|
|
useEffect(() => { onObservationRef.current = onObservation; }, [onObservation]);
|
|
useEffect(() => { onFingerprintRotatedRef.current = onFingerprintRotated; }, [onFingerprintRotated]);
|
|
useEffect(() => { onScoredRef.current = onScored; }, [onScored]);
|
|
useEffect(() => { onAttributionStateChangedRef.current = onAttributionStateChanged; }, [onAttributionStateChanged]);
|
|
useEffect(() => { onMultiActorSuspectedRef.current = onMultiActorSuspected; }, [onMultiActorSuspected]);
|
|
useEffect(() => { onErrorRef.current = onError; }, [onError]);
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !attackerUuid) 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}/attackers/${encodeURIComponent(attackerUuid)}/events?token=${encodeURIComponent(token)}`;
|
|
|
|
const es = new EventSource(url);
|
|
esRef.current = es;
|
|
|
|
const handle = (name: string) => (event: MessageEvent) => {
|
|
let parsed: AttackerStreamEvent;
|
|
try {
|
|
parsed = JSON.parse(event.data) as AttackerStreamEvent;
|
|
} catch (err) {
|
|
console.error('useAttackerStream: parse failed', err);
|
|
return;
|
|
}
|
|
const payload = (parsed.payload ?? parsed) as Record<string, unknown>;
|
|
switch (name) {
|
|
case 'snapshot':
|
|
onSnapshotRef.current?.(payload as unknown as SnapshotFrame);
|
|
break;
|
|
case 'observation':
|
|
onObservationRef.current?.(payload as unknown as ObservationFrame);
|
|
break;
|
|
case 'fingerprint.rotated':
|
|
onFingerprintRotatedRef.current?.(payload);
|
|
break;
|
|
case 'attacker.scored':
|
|
onScoredRef.current?.(payload);
|
|
break;
|
|
case 'attribution.state_changed':
|
|
onAttributionStateChangedRef.current?.(
|
|
payload as unknown as AttributionStateChangedFrame,
|
|
);
|
|
break;
|
|
case 'attribution.multi_actor_suspected':
|
|
onMultiActorSuspectedRef.current?.(
|
|
payload as unknown as AttributionMultiActorFrame,
|
|
);
|
|
break;
|
|
}
|
|
};
|
|
|
|
for (const name of NAMED_EVENTS) {
|
|
es.addEventListener(name, handle(name) as EventListener);
|
|
}
|
|
|
|
es.onerror = () => {
|
|
es.close();
|
|
esRef.current = null;
|
|
onErrorRef.current?.();
|
|
reconnectRef.current = setTimeout(connect, RECONNECT_MS);
|
|
};
|
|
};
|
|
|
|
connect();
|
|
|
|
return () => {
|
|
if (reconnectRef.current) clearTimeout(reconnectRef.current);
|
|
if (esRef.current) esRef.current.close();
|
|
esRef.current = null;
|
|
};
|
|
}, [enabled, attackerUuid]);
|
|
}
|