/** The graph endpoints. Same base-URL rule as the rest of the console. */ import { API_BASE } from "../../api/client"; import type { GraphPayload } from "./model"; async function get(path: string, signal?: AbortSignal): Promise { const response = await fetch(`${API_BASE}${path}`, { signal }); if (!response.ok) { let detail = response.statusText; try { const body = await response.json(); if (typeof body?.detail === "string") detail = body.detail; } catch { /* the status line is all we have */ } throw new Error(detail); } return (await response.json()) as T; } export interface NeighborsPage extends GraphPayload { node_id: string; total: number; returned: number; offset: number; has_more: boolean; remaining: number; } export interface TracePayload extends GraphPayload { root: string; direction: "forward" | "backward"; pruned_edges: number; wallets: number; } export interface PathPayload extends GraphPayload { from: string; to: string; hops: number; path: string[]; } export interface ClusterPayload extends GraphPayload { cluster_id: string; size: number; risk: number; flag: string | null; } const q = (value: string) => encodeURIComponent(value); export const graphApi = { neighbors: ( id: string, { direction = "both", limit = 20, offset = 0 }: { direction?: "in" | "out" | "both"; limit?: number; offset?: number; } = {}, signal?: AbortSignal, ) => get( `/graph/nodes/${q(id)}/neighbors?direction=${direction}&limit=${limit}&offset=${offset}`, signal, ), trace: ( from: string, { direction = "forward", maxHops = 4, minAmount = 0, }: { direction?: "forward" | "backward"; maxHops?: number; minAmount?: number } = {}, signal?: AbortSignal, ) => get( `/graph/trace?from=${q(from)}&direction=${direction}&max_hops=${maxHops}&min_amount=${minAmount}`, signal, ), taint: (seed: string, maxHops = 4, signal?: AbortSignal) => get<{ seed: string; seed_entity: string; tainted: { entity_id: string; score: number; hops: number; path: string[] }[]; total: number; caveat: string; }>(`/graph/taint?seed=${q(seed)}&max_hops=${maxHops}`, signal), path: (from: string, to: string, signal?: AbortSignal) => get(`/graph/path?from=${q(from)}&to=${q(to)}`, signal), cluster: (id: string, signal?: AbortSignal) => get(`/graph/clusters/${q(id)}`, signal), async save(name: string, state: unknown) { const response = await fetch(`${API_BASE}/investigations`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, state }), }); if (!response.ok) throw new Error(`could not save: ${response.statusText}`); return (await response.json()) as { id: string; name: string; saved_at: string }; }, load: (id: string, signal?: AbortSignal) => get<{ id: string; name: string; saved_at: string; state: Record }>( `/investigations/${q(id)}`, signal, ), };