feat(decnet_web/AttackerDetail): attribution state badges (Phase 6)
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.
This commit is contained in:
@@ -4,8 +4,24 @@ import { render, screen } from '@testing-library/react';
|
|||||||
import {
|
import {
|
||||||
BehaviouralPrimitivesPanel,
|
BehaviouralPrimitivesPanel,
|
||||||
type BehaviouralObservation,
|
type BehaviouralObservation,
|
||||||
|
type AttributionPrimitiveState,
|
||||||
} from './AttackerDetail';
|
} from './AttackerDetail';
|
||||||
|
|
||||||
|
const _attr = (
|
||||||
|
primitive: string,
|
||||||
|
state: AttributionPrimitiveState['state'],
|
||||||
|
confidence = 0.85,
|
||||||
|
observation_count = 6,
|
||||||
|
): AttributionPrimitiveState => ({
|
||||||
|
primitive,
|
||||||
|
current_value: 'x',
|
||||||
|
state,
|
||||||
|
confidence,
|
||||||
|
observation_count,
|
||||||
|
last_change_ts: 1714000000,
|
||||||
|
last_observation_ts: 1714000300,
|
||||||
|
});
|
||||||
|
|
||||||
const _obs = (
|
const _obs = (
|
||||||
primitive: string,
|
primitive: string,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
@@ -66,6 +82,75 @@ describe('BehaviouralPrimitivesPanel', () => {
|
|||||||
expect(row.textContent).toContain('91%');
|
expect(row.textContent).toContain('91%');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders attribution badges only for primitives in the map', () => {
|
||||||
|
const observations: BehaviouralObservation[] = [
|
||||||
|
_obs('motor.input_modality', 'pasted', 0.91),
|
||||||
|
_obs('cognitive.feedback_loop_engagement', 'closed_loop', 0.88),
|
||||||
|
];
|
||||||
|
const attribution = new Map<string, AttributionPrimitiveState>([
|
||||||
|
['motor.input_modality', _attr('motor.input_modality', 'stable', 0.95)],
|
||||||
|
// cognitive.feedback_loop_engagement intentionally absent —
|
||||||
|
// the panel must not render a badge for it.
|
||||||
|
]);
|
||||||
|
render(
|
||||||
|
<BehaviouralPrimitivesPanel
|
||||||
|
observations={observations}
|
||||||
|
attribution={attribution}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const badge = screen.getByTestId('attribution-badge-motor.input_modality');
|
||||||
|
expect(badge.textContent).toBe('STABLE');
|
||||||
|
expect(badge.getAttribute('data-state')).toBe('stable');
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId(
|
||||||
|
'attribution-badge-cognitive.feedback_loop_engagement',
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders each of the five frozen states with a distinct label', () => {
|
||||||
|
const cases: [AttributionPrimitiveState['state'], string][] = [
|
||||||
|
['stable', 'STABLE'],
|
||||||
|
['drifting', 'DRIFTING'],
|
||||||
|
['conflicted', 'CONFLICTED'],
|
||||||
|
['multi_actor', 'MULTI-ACTOR'],
|
||||||
|
['unknown', 'UNKNOWN'],
|
||||||
|
];
|
||||||
|
const observations: BehaviouralObservation[] = cases.map((_pair, i) =>
|
||||||
|
_obs(`motor.synthetic_${i}`, 'x'),
|
||||||
|
);
|
||||||
|
const attribution = new Map(
|
||||||
|
cases.map(([state], i) => [
|
||||||
|
`motor.synthetic_${i}`,
|
||||||
|
_attr(`motor.synthetic_${i}`, state),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<BehaviouralPrimitivesPanel
|
||||||
|
observations={observations}
|
||||||
|
attribution={attribution}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
for (const [state, label] of cases) {
|
||||||
|
const idx = cases.findIndex(([s]) => s === state);
|
||||||
|
const badge = screen.getByTestId(
|
||||||
|
`attribution-badge-motor.synthetic_${idx}`,
|
||||||
|
);
|
||||||
|
expect(badge.textContent).toBe(label);
|
||||||
|
expect(badge.getAttribute('data-state')).toBe(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render badges when no attribution prop is provided', () => {
|
||||||
|
const observations: BehaviouralObservation[] = [
|
||||||
|
_obs('motor.input_modality', 'pasted'),
|
||||||
|
];
|
||||||
|
render(<BehaviouralPrimitivesPanel observations={observations} />);
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('attribution-badge-motor.input_modality'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('groups by top-level domain in the canonical order', () => {
|
it('groups by top-level domain in the canonical order', () => {
|
||||||
const observations: BehaviouralObservation[] = [
|
const observations: BehaviouralObservation[] = [
|
||||||
_obs('emotional_valence.arousal', 'medium_engaged'),
|
_obs('emotional_valence.arousal', 'medium_engaged'),
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import SessionDrawer from './SessionDrawer';
|
|||||||
import EmptyState from './EmptyState/EmptyState';
|
import EmptyState from './EmptyState/EmptyState';
|
||||||
import TTPsObservedSection from './TTPsObservedSection';
|
import TTPsObservedSection from './TTPsObservedSection';
|
||||||
import { useIdentityStream } from './useIdentityStream';
|
import { useIdentityStream } from './useIdentityStream';
|
||||||
import { useAttackerStream, type ObservationFrame } from './useAttackerStream';
|
import {
|
||||||
|
useAttackerStream,
|
||||||
|
type ObservationFrame,
|
||||||
|
type AttributionStateChangedFrame,
|
||||||
|
type AttributionMultiActorFrame,
|
||||||
|
} from './useAttackerStream';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
|
|
||||||
interface AttackerBehavior {
|
interface AttackerBehavior {
|
||||||
@@ -113,6 +118,21 @@ export interface BehaviouralObservation {
|
|||||||
source?: string;
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-(identity, primitive) attribution state — derived by the
|
||||||
|
// attribution engine. Keyed by primitive when consumed by
|
||||||
|
// BehaviouralPrimitivesPanel; the API also returns identity_uuid +
|
||||||
|
// timestamps which the panel doesn't render but the live SSE handler
|
||||||
|
// uses to merge updates.
|
||||||
|
export interface AttributionPrimitiveState {
|
||||||
|
primitive: string;
|
||||||
|
current_value: unknown;
|
||||||
|
state: 'unknown' | 'stable' | 'drifting' | 'conflicted' | 'multi_actor';
|
||||||
|
confidence: number;
|
||||||
|
observation_count: number;
|
||||||
|
last_change_ts: number;
|
||||||
|
last_observation_ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Fingerprint rendering ───────────────────────────────────────────────────
|
// ─── Fingerprint rendering ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const fpTypeLabel: Record<string, string> = {
|
const fpTypeLabel: Record<string, string> = {
|
||||||
@@ -954,9 +974,53 @@ function _renderValue(value: unknown): string {
|
|||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-state badge styling. Five states, frozen vocabulary —
|
||||||
|
// matches decnet/correlation/attribution/aggregate.py. multi_actor is
|
||||||
|
// the loudest because the cross-primitive correlator (Phase 5) only
|
||||||
|
// fires multi_actor_suspected when >= 2 primitives flag it.
|
||||||
|
const ATTRIBUTION_STATE_STYLE: Record<
|
||||||
|
AttributionPrimitiveState['state'],
|
||||||
|
{ label: string; bg: string; fg: string; border: string }
|
||||||
|
> = {
|
||||||
|
stable: { label: 'STABLE', bg: 'rgba(64,224,128,0.12)', fg: '#7fe9a4', border: '#3a8c5a' },
|
||||||
|
drifting: { label: 'DRIFTING', bg: 'rgba(240,196,64,0.12)', fg: '#f0c440', border: '#a08020' },
|
||||||
|
conflicted: { label: 'CONFLICTED', bg: 'rgba(240,96,96,0.12)', fg: '#f06060', border: '#a04040' },
|
||||||
|
multi_actor: { label: 'MULTI-ACTOR', bg: 'rgba(180,96,240,0.16)', fg: '#c896f6', border: '#7a4fb0' },
|
||||||
|
unknown: { label: 'UNKNOWN', bg: 'transparent', fg: 'var(--text-dim,#888)', border: 'var(--border-color)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const AttributionBadge: React.FC<{ state: AttributionPrimitiveState }> = ({ state }) => {
|
||||||
|
const style = ATTRIBUTION_STATE_STYLE[state.state] ?? ATTRIBUTION_STATE_STYLE.unknown;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="attribution-badge"
|
||||||
|
data-testid={`attribution-badge-${state.primitive}`}
|
||||||
|
data-state={state.state}
|
||||||
|
title={
|
||||||
|
`${style.label} • confidence ${(state.confidence * 100).toFixed(0)}% ` +
|
||||||
|
`over ${state.observation_count} observation${state.observation_count === 1 ? '' : 's'}`
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
letterSpacing: '1px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
background: style.bg,
|
||||||
|
color: style.fg,
|
||||||
|
border: `1px solid ${style.border}`,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{style.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const BehaviouralPrimitivesPanel: React.FC<{
|
export const BehaviouralPrimitivesPanel: React.FC<{
|
||||||
observations: ReadonlyArray<BehaviouralObservation>;
|
observations: ReadonlyArray<BehaviouralObservation>;
|
||||||
}> = ({ observations }) => {
|
attribution?: ReadonlyMap<string, AttributionPrimitiveState>;
|
||||||
|
}> = ({ observations, attribution }) => {
|
||||||
if (!observations.length) {
|
if (!observations.length) {
|
||||||
return (
|
return (
|
||||||
<div className="info-banner" data-testid="behaviour-empty">
|
<div className="info-banner" data-testid="behaviour-empty">
|
||||||
@@ -1036,6 +1100,9 @@ export const BehaviouralPrimitivesPanel: React.FC<{
|
|||||||
>
|
>
|
||||||
{_renderValue(obs.value)}
|
{_renderValue(obs.value)}
|
||||||
</span>
|
</span>
|
||||||
|
{attribution?.get(obs.primitive) ? (
|
||||||
|
<AttributionBadge state={attribution.get(obs.primitive)!} />
|
||||||
|
) : null}
|
||||||
<span
|
<span
|
||||||
className="behaviour-confidence dim"
|
className="behaviour-confidence dim"
|
||||||
style={{
|
style={{
|
||||||
@@ -1437,6 +1504,13 @@ const AttackerDetail: React.FC = () => {
|
|||||||
// attacker.observations on first fetch; mutated in place by the
|
// attacker.observations on first fetch; mutated in place by the
|
||||||
// useAttackerStream hook below (latest-wins per primitive).
|
// useAttackerStream hook below (latest-wins per primitive).
|
||||||
const [observations, setObservations] = useState<BehaviouralObservation[]>([]);
|
const [observations, setObservations] = useState<BehaviouralObservation[]>([]);
|
||||||
|
// Attribution-engine state per primitive. Seeded from
|
||||||
|
// GET /attackers/{id}/attribution on mount; live-updated via
|
||||||
|
// attribution.state_changed SSE frames. Map keyed on primitive
|
||||||
|
// for O(1) badge lookup in the panel.
|
||||||
|
const [attribution, setAttribution] = useState<Map<string, AttributionPrimitiveState>>(
|
||||||
|
() => new Map(),
|
||||||
|
);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
|
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
|
||||||
@@ -1528,6 +1602,28 @@ const AttackerDetail: React.FC = () => {
|
|||||||
fetchAttacker();
|
fetchAttacker();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
// Fetch attribution state on mount + whenever the attacker uuid
|
||||||
|
// changes. Quietly tolerates 404s (the attribution worker may be
|
||||||
|
// off in a dev decky).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/attackers/${id}/attribution`);
|
||||||
|
if (cancelled) return;
|
||||||
|
const next = new Map<string, AttributionPrimitiveState>();
|
||||||
|
const primitives = (res.data?.primitives ?? []) as AttributionPrimitiveState[];
|
||||||
|
for (const row of primitives) next.set(row.primitive, row);
|
||||||
|
setAttribution(next);
|
||||||
|
} catch {
|
||||||
|
// Endpoint optional in dev; the panel will simply not render
|
||||||
|
// badges. Don't surface the error to the user.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
// Re-fetch this attacker row whenever an identity event references
|
// Re-fetch this attacker row whenever an identity event references
|
||||||
// its uuid. The IDENTITY badge appears once the clusterer binds the
|
// its uuid. The IDENTITY badge appears once the clusterer binds the
|
||||||
// row, and follows through merges / unmerges live.
|
// row, and follows through merges / unmerges live.
|
||||||
@@ -1584,6 +1680,40 @@ const AttackerDetail: React.FC = () => {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
// Live attribution-state badge updates. Backend filters on
|
||||||
|
// identity_uuid so we only see frames for this attacker's
|
||||||
|
// identity; merge by primitive.
|
||||||
|
onAttributionStateChanged: (frame: AttributionStateChangedFrame) => {
|
||||||
|
setAttribution((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const prior = next.get(frame.primitive);
|
||||||
|
next.set(frame.primitive, {
|
||||||
|
primitive: frame.primitive,
|
||||||
|
current_value: frame.current_value,
|
||||||
|
state: frame.new_state,
|
||||||
|
confidence: frame.confidence,
|
||||||
|
observation_count: frame.observation_count,
|
||||||
|
// last_change_ts is derived: this frame IS a transition, so
|
||||||
|
// it locks here. last_observation_ts comes from the frame.
|
||||||
|
last_change_ts: frame.ts,
|
||||||
|
last_observation_ts: frame.ts,
|
||||||
|
// Carry forward the prior change ts only when state didn't
|
||||||
|
// actually flip (defensive — backend gates these on
|
||||||
|
// transition, but a future relaxation shouldn't lie about
|
||||||
|
// "stable since X").
|
||||||
|
...(prior && prior.state === frame.new_state
|
||||||
|
? { last_change_ts: prior.last_change_ts }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onMultiActorSuspected: (_frame: AttributionMultiActorFrame) => {
|
||||||
|
// The per-primitive badges already reflect multi_actor on each
|
||||||
|
// contributing primitive; the cross-primitive escalation is a
|
||||||
|
// SIEM-channel signal, not a UI-only badge. Listener wired so
|
||||||
|
// a future "two operators detected" banner has a live source.
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1972,7 +2102,7 @@ const AttackerDetail: React.FC = () => {
|
|||||||
open={openSections.behavioural}
|
open={openSections.behavioural}
|
||||||
onToggle={() => toggle('behavioural')}
|
onToggle={() => toggle('behavioural')}
|
||||||
>
|
>
|
||||||
<BehaviouralPrimitivesPanel observations={observations} />
|
<BehaviouralPrimitivesPanel observations={observations} attribution={attribution} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Commands */}
|
{/* Commands */}
|
||||||
|
|||||||
@@ -19,6 +19,15 @@
|
|||||||
* primitive rides in `payload.primitive`.
|
* primitive rides in `payload.primitive`.
|
||||||
* * `fingerprint.rotated` — `attacker.fingerprint_rotated`.
|
* * `fingerprint.rotated` — `attacker.fingerprint_rotated`.
|
||||||
* * `attacker.scored` — score-threshold crossings.
|
* * `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';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
@@ -36,11 +45,39 @@ export interface SnapshotFrame {
|
|||||||
observations: ObservationFrame[];
|
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 =
|
export type AttackerStreamEventName =
|
||||||
| 'snapshot'
|
| 'snapshot'
|
||||||
| 'observation'
|
| 'observation'
|
||||||
| 'fingerprint.rotated'
|
| 'fingerprint.rotated'
|
||||||
| 'attacker.scored';
|
| 'attacker.scored'
|
||||||
|
| 'attribution.state_changed'
|
||||||
|
| 'attribution.multi_actor_suspected';
|
||||||
|
|
||||||
export interface AttackerStreamEvent {
|
export interface AttackerStreamEvent {
|
||||||
name: AttackerStreamEventName | string;
|
name: AttackerStreamEventName | string;
|
||||||
@@ -57,6 +94,8 @@ export interface UseAttackerStreamOptions {
|
|||||||
onObservation?: (data: ObservationFrame) => void;
|
onObservation?: (data: ObservationFrame) => void;
|
||||||
onFingerprintRotated?: (data: Record<string, unknown>) => void;
|
onFingerprintRotated?: (data: Record<string, unknown>) => void;
|
||||||
onScored?: (data: Record<string, unknown>) => void;
|
onScored?: (data: Record<string, unknown>) => void;
|
||||||
|
onAttributionStateChanged?: (data: AttributionStateChangedFrame) => void;
|
||||||
|
onMultiActorSuspected?: (data: AttributionMultiActorFrame) => void;
|
||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +104,8 @@ const NAMED_EVENTS: AttackerStreamEventName[] = [
|
|||||||
'observation',
|
'observation',
|
||||||
'fingerprint.rotated',
|
'fingerprint.rotated',
|
||||||
'attacker.scored',
|
'attacker.scored',
|
||||||
|
'attribution.state_changed',
|
||||||
|
'attribution.multi_actor_suspected',
|
||||||
];
|
];
|
||||||
|
|
||||||
const RECONNECT_MS = 3000;
|
const RECONNECT_MS = 3000;
|
||||||
@@ -76,6 +117,8 @@ export function useAttackerStream({
|
|||||||
onObservation,
|
onObservation,
|
||||||
onFingerprintRotated,
|
onFingerprintRotated,
|
||||||
onScored,
|
onScored,
|
||||||
|
onAttributionStateChanged,
|
||||||
|
onMultiActorSuspected,
|
||||||
onError,
|
onError,
|
||||||
}: UseAttackerStreamOptions): void {
|
}: UseAttackerStreamOptions): void {
|
||||||
const esRef = useRef<EventSource | null>(null);
|
const esRef = useRef<EventSource | null>(null);
|
||||||
@@ -84,11 +127,15 @@ export function useAttackerStream({
|
|||||||
const onObservationRef = useRef(onObservation);
|
const onObservationRef = useRef(onObservation);
|
||||||
const onFingerprintRotatedRef = useRef(onFingerprintRotated);
|
const onFingerprintRotatedRef = useRef(onFingerprintRotated);
|
||||||
const onScoredRef = useRef(onScored);
|
const onScoredRef = useRef(onScored);
|
||||||
|
const onAttributionStateChangedRef = useRef(onAttributionStateChanged);
|
||||||
|
const onMultiActorSuspectedRef = useRef(onMultiActorSuspected);
|
||||||
const onErrorRef = useRef(onError);
|
const onErrorRef = useRef(onError);
|
||||||
useEffect(() => { onSnapshotRef.current = onSnapshot; }, [onSnapshot]);
|
useEffect(() => { onSnapshotRef.current = onSnapshot; }, [onSnapshot]);
|
||||||
useEffect(() => { onObservationRef.current = onObservation; }, [onObservation]);
|
useEffect(() => { onObservationRef.current = onObservation; }, [onObservation]);
|
||||||
useEffect(() => { onFingerprintRotatedRef.current = onFingerprintRotated; }, [onFingerprintRotated]);
|
useEffect(() => { onFingerprintRotatedRef.current = onFingerprintRotated; }, [onFingerprintRotated]);
|
||||||
useEffect(() => { onScoredRef.current = onScored; }, [onScored]);
|
useEffect(() => { onScoredRef.current = onScored; }, [onScored]);
|
||||||
|
useEffect(() => { onAttributionStateChangedRef.current = onAttributionStateChanged; }, [onAttributionStateChanged]);
|
||||||
|
useEffect(() => { onMultiActorSuspectedRef.current = onMultiActorSuspected; }, [onMultiActorSuspected]);
|
||||||
useEffect(() => { onErrorRef.current = onError; }, [onError]);
|
useEffect(() => { onErrorRef.current = onError; }, [onError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,6 +172,16 @@ export function useAttackerStream({
|
|||||||
case 'attacker.scored':
|
case 'attacker.scored':
|
||||||
onScoredRef.current?.(payload);
|
onScoredRef.current?.(payload);
|
||||||
break;
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user