refactor(decnet_web/AttackerDetail): extract data layer into useAttackerDetail
The AttackerDetail page body owned all 7 REST fetches plus 2 SSE
streams inline as 200+ lines of useEffect plumbing. Lift them into
a single hook so section components extracted in follow-up commits
consume typed values, not setState pairs.
- New ./AttackerDetail/types.ts holds the canonical AttackerData,
BehaviouralObservation, AttributionPrimitiveState plus newly-named
ArtifactLog / SessionLog / SmtpTargetRow / MailLog / CommandRow
(previously inline anonymous types).
- New ./AttackerDetail/useAttackerDetail.ts owns:
* GET /attackers/:id (404 -> ATTACKER NOT FOUND)
* GET /attackers/:id/attribution (silent-tolerant)
* GET /attackers/:id/commands paged with 422 alert preserved
* GET /attackers/:id/{artifacts,smtp-targets,mail,transcripts}
(mail surfaces a 403 boolean for the admin-gated viewer)
* useAttackerStream + useIdentityStream subscriptions, including
the live attribution-state-changed merge.
- AttackerDetail.tsx re-exports BehaviouralObservation /
AttributionPrimitiveState so AttackerDetail.behaviour_panel.test
and any future external importer keeps working unchanged.
- New useAttackerDetail.test.ts covers loading -> success, 404,
paged commands offset, serviceFilter resets cmdPage, and mail 403
via MSW handlers (the SSE hooks are vi.mock'd; jsdom can't host
EventSource).
No behavior change for the rendered page; all 37 tests green.
This commit is contained in:
312
decnet_web/src/components/AttackerDetail/useAttackerDetail.ts
Normal file
312
decnet_web/src/components/AttackerDetail/useAttackerDetail.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import { useIdentityStream } from '../useIdentityStream';
|
||||
import {
|
||||
useAttackerStream,
|
||||
type ObservationFrame,
|
||||
type AttributionStateChangedFrame,
|
||||
type AttributionMultiActorFrame,
|
||||
} from '../useAttackerStream';
|
||||
import type {
|
||||
AttackerData,
|
||||
BehaviouralObservation,
|
||||
AttributionPrimitiveState,
|
||||
ArtifactLog,
|
||||
SessionLog,
|
||||
SmtpTargetRow,
|
||||
MailLog,
|
||||
CommandRow,
|
||||
} from './types';
|
||||
|
||||
export const COMMAND_PAGE_SIZE = 50;
|
||||
|
||||
export interface UseAttackerDetailResult {
|
||||
attacker: AttackerData | null;
|
||||
observations: BehaviouralObservation[];
|
||||
attribution: Map<string, AttributionPrimitiveState>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Commands paging
|
||||
commands: CommandRow[];
|
||||
cmdTotal: number;
|
||||
cmdPage: number;
|
||||
setCmdPage: (n: number) => void;
|
||||
serviceFilter: string | null;
|
||||
setServiceFilter: (s: string | null) => void;
|
||||
cmdLimit: number;
|
||||
|
||||
// Auxiliary feeds
|
||||
artifacts: ArtifactLog[];
|
||||
smtpTargets: SmtpTargetRow[];
|
||||
mail: MailLog[];
|
||||
mailForbidden: boolean;
|
||||
sessions: SessionLog[];
|
||||
}
|
||||
|
||||
interface ApiErrorLike {
|
||||
response?: { status?: number };
|
||||
}
|
||||
|
||||
const isApiError = (e: unknown): e is ApiErrorLike =>
|
||||
typeof e === 'object' && e !== null && 'response' in e;
|
||||
|
||||
/** Owns every read-side data flow for the AttackerDetail page —
|
||||
* REST fetches plus the per-attacker and per-identity SSE streams.
|
||||
* Section components consume the returned values; none of them
|
||||
* open their own connections. */
|
||||
export function useAttackerDetail(id: string | undefined): UseAttackerDetailResult {
|
||||
const [attacker, setAttacker] = useState<AttackerData | null>(null);
|
||||
const [observations, setObservations] = useState<BehaviouralObservation[]>([]);
|
||||
const [attribution, setAttribution] = useState<Map<string, AttributionPrimitiveState>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
|
||||
const [commands, setCommands] = useState<CommandRow[]>([]);
|
||||
const [cmdTotal, setCmdTotal] = useState(0);
|
||||
const [cmdPage, setCmdPage] = useState(1);
|
||||
const cmdLimit = COMMAND_PAGE_SIZE;
|
||||
|
||||
const [artifacts, setArtifacts] = useState<ArtifactLog[]>([]);
|
||||
const [smtpTargets, setSmtpTargets] = useState<SmtpTargetRow[]>([]);
|
||||
const [mail, setMail] = useState<MailLog[]>([]);
|
||||
const [mailForbidden, setMailForbidden] = useState(false);
|
||||
const [sessions, setSessions] = useState<SessionLog[]>([]);
|
||||
|
||||
// Primary attacker fetch.
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get(`/attackers/${id}`);
|
||||
if (cancelled) return;
|
||||
setAttacker(res.data);
|
||||
setObservations(res.data?.observations ?? []);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
if (isApiError(err) && err.response?.status === 404) {
|
||||
setError('ATTACKER NOT FOUND');
|
||||
} else {
|
||||
setError('FAILED TO LOAD ATTACKER PROFILE');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
// Attribution table; tolerated 404/network failure (worker may be off).
|
||||
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 {
|
||||
// optional endpoint
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
// Identity-event refresh: re-fetch attacker row when an identity
|
||||
// event references either this attacker uuid or its bound identity.
|
||||
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.observation_uuid);
|
||||
const obsList = payload.observation_uuids;
|
||||
if (Array.isArray(obsList)) obsList.forEach(addUuid);
|
||||
addUuid(payload.identity_uuid);
|
||||
addUuid(payload.winner_uuid);
|
||||
addUuid(payload.loser_uuid);
|
||||
addUuid(payload.resurrected_uuid);
|
||||
addUuid(payload.former_winner_uuid);
|
||||
|
||||
const myIdentity = attacker?.identity_id;
|
||||
if (refs.has(id) || (myIdentity && refs.has(myIdentity))) {
|
||||
api.get(`/attackers/${id}`)
|
||||
.then((res) => setAttacker(res.data))
|
||||
.catch(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Per-attacker live behaviour + attribution updates.
|
||||
useAttackerStream({
|
||||
attackerUuid: id ?? '',
|
||||
enabled: !!id,
|
||||
onSnapshot: (data) => {
|
||||
setObservations(data.observations ?? []);
|
||||
},
|
||||
onObservation: (frame: ObservationFrame) => {
|
||||
setObservations((prev) => {
|
||||
const filtered = prev.filter((o) => o.primitive !== frame.primitive);
|
||||
return [
|
||||
...filtered,
|
||||
{
|
||||
primitive: frame.primitive,
|
||||
value: frame.value,
|
||||
confidence: frame.confidence,
|
||||
ts: frame.ts,
|
||||
source: frame.source,
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
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: frame.ts,
|
||||
last_observation_ts: frame.ts,
|
||||
...(prior && prior.state === frame.new_state
|
||||
? { last_change_ts: prior.last_change_ts }
|
||||
: {}),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onMultiActorSuspected: (_frame: AttributionMultiActorFrame) => {
|
||||
// Cross-primitive escalation is a SIEM-channel signal.
|
||||
// Listener is wired so a future banner has a live source.
|
||||
},
|
||||
});
|
||||
|
||||
// Paged command list — re-fetches on page or filter change.
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const offset = (cmdPage - 1) * cmdLimit;
|
||||
let url = `/attackers/${id}/commands?limit=${cmdLimit}&offset=${offset}`;
|
||||
if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`;
|
||||
const res = await api.get(url);
|
||||
if (cancelled) return;
|
||||
setCommands(res.data.data);
|
||||
setCmdTotal(res.data.total);
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
if (isApiError(err) && err.response?.status === 422) {
|
||||
// Backend gate hit a malformed filter; surface loudly so a
|
||||
// user typo (e.g. unknown service) is visible immediately.
|
||||
alert('Fuck off.');
|
||||
}
|
||||
setCommands([]);
|
||||
setCmdTotal(0);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [id, cmdPage, serviceFilter, cmdLimit]);
|
||||
|
||||
// Reset to page 1 whenever filter flips.
|
||||
useEffect(() => {
|
||||
setCmdPage(1);
|
||||
}, [serviceFilter]);
|
||||
|
||||
// Static auxiliary feeds — single-shot per id.
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get(`/attackers/${id}/artifacts`);
|
||||
if (!cancelled) setArtifacts(res.data.data ?? []);
|
||||
} catch {
|
||||
if (!cancelled) setArtifacts([]);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get(`/attackers/${id}/smtp-targets`);
|
||||
if (!cancelled) setSmtpTargets(res.data.data ?? []);
|
||||
} catch {
|
||||
if (!cancelled) setSmtpTargets([]);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get(`/attackers/${id}/mail`);
|
||||
if (cancelled) return;
|
||||
setMail(res.data.data ?? []);
|
||||
setMailForbidden(false);
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
setMail([]);
|
||||
setMailForbidden(isApiError(err) && err.response?.status === 403);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get(`/attackers/${id}/transcripts`);
|
||||
if (!cancelled) setSessions(res.data.data ?? []);
|
||||
} catch {
|
||||
if (!cancelled) setSessions([]);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
return {
|
||||
attacker,
|
||||
observations,
|
||||
attribution,
|
||||
loading,
|
||||
error,
|
||||
commands,
|
||||
cmdTotal,
|
||||
cmdPage,
|
||||
setCmdPage,
|
||||
serviceFilter,
|
||||
setServiceFilter,
|
||||
cmdLimit,
|
||||
artifacts,
|
||||
smtpTargets,
|
||||
mail,
|
||||
mailForbidden,
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user