/** * Typed fetch wrappers for the L4 Perception Label Evaluation API, * plus a useStream hook (SSE with polling fallback) and a usePoll helper. */ import { useEffect, useRef, useState, useCallback } from 'react'; import type { AnomalyConfig, Alert, AuditEvent, BenchmarkResponse, CopilotExplainRequest, CopilotExplainResponse, DatasetSummary, EvaluationRecord, FrameSummary, FunnelResponse, GateLine, HaystackPoint, ModelSummary, OverviewResponse, PipelineStateResponse, ProcessUnitsResponse, QualityGroupDetail, QualityGroupsResponse, QualityMetrics, QueueStatus, RareEvent, RegressionResponse, ReviewActionRequest, ReviewActionResponse, ReviewTask, StreamEvent, TrainingJobStatus, TrainRequest, TrainResponse, } from '../types/labeleval'; import type { MegaevalStatus } from '../types/megaeval'; import { getMegaevalStatus } from './megaeval'; async function handle(res: Response): Promise { if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error(`API ${res.status}: ${body || res.statusText}`); } return res.json() as Promise; } const JSON_HEADERS = { 'Content-Type': 'application/json' }; function get(url: string): Promise { return fetch(url).then((res) => handle(res)); } function post(url: string, body?: unknown): Promise { return fetch(url, { method: 'POST', headers: JSON_HEADERS, body: body === undefined ? undefined : JSON.stringify(body), }).then((res) => handle(res)); } // ---------------------------------------------------------------- responses not in the shared contract export interface PrecheckResponse { status: string; message: string; checks: GateLine[]; } export interface GenerateDatasetRequest { name?: string; num_sequences?: number; frames_per_sequence?: number; seed?: number; } export interface RunResponse { run_id: string; status: string; } export interface FrameResponse { frame: FrameSummary; prev: string | null; next: string | null; } // ---------------------------------------------------------------- endpoints export const getOverview = () => get('/api/labeleval/overview'); export const getPipeline = () => get('/api/labeleval/pipeline'); export const getFunnel = () => get('/api/labeleval/funnel'); export const getQueueStatus = () => get('/api/queue/status'); export const getDatasets = () => get<{ datasets: DatasetSummary[] }>('/api/datasets'); export const getDataset = (id: string) => get(`/api/datasets/${encodeURIComponent(id)}`); export const precheckDataset = (datasetId?: string) => post('/api/dataset/precheck', datasetId ? { dataset_id: datasetId } : {}); export const generateDataset = (body: GenerateDatasetRequest) => post('/api/labeleval/datasets/generate', body); export const runPipeline = (datasetId: string, policyId?: string) => post('/api/labeleval/run', { dataset_id: datasetId, ...(policyId ? { policy_id: policyId } : {}) }); export const getFrameIds = (datasetId: string) => get<{ frame_ids: string[] }>(`/api/labeleval/frames?dataset_id=${encodeURIComponent(datasetId)}`); export const getFrame = (frameId: string) => get(`/api/labeleval/frames/${encodeURIComponent(frameId)}`); export const getEvaluation = (annotationId: string) => get(`/api/labeleval/evaluations/${encodeURIComponent(annotationId)}`); export const getHaystack = (datasetId?: string | null) => get<{ points: HaystackPoint[] }>( datasetId ? `/api/labeleval/haystack?dataset_id=${encodeURIComponent(datasetId)}` : '/api/labeleval/haystack' ); export const getRareEvents = () => get<{ events: RareEvent[] }>('/api/labeleval/rare-events'); export const getQualityMetrics = (datasetId?: string | null) => get( datasetId ? `/api/quality/metrics?dataset_id=${encodeURIComponent(datasetId)}` : '/api/quality/metrics' ); export const getQualityGroups = (datasetId?: string | null) => get( datasetId ? `/api/quality/groups?dataset_id=${encodeURIComponent(datasetId)}` : '/api/quality/groups' ); export const getQualityGroupDetail = (groupId: string) => get(`/api/quality/groups/${encodeURIComponent(groupId)}`); export const getRegression = () => get('/api/regression'); export const getReviewTasks = () => get<{ tasks: ReviewTask[] }>('/api/review/tasks'); export const getReviewTask = (id: string) => get(`/api/review/tasks/${encodeURIComponent(id)}`); export const postReviewAction = (id: string, body: ReviewActionRequest) => post(`/api/review/tasks/${encodeURIComponent(id)}`, body); export const getModels = () => get<{ models: ModelSummary[] }>('/api/models'); export const postTrain = (body: TrainRequest) => post('/api/train', body); export const getTrainJobs = () => get<{ jobs: TrainingJobStatus[] }>('/api/train/jobs'); export const getTrainJob = (jobId: string) => get(`/api/train/jobs/${encodeURIComponent(jobId)}`); export const getAnomalyConfig = () => get('/api/labeleval/config'); export const postAnomalyConfig = (body: AnomalyConfig) => post('/api/labeleval/config', body); export const runBenchmark = (datasetId?: string | null) => post('/api/benchmark/techniques', datasetId ? { dataset_id: datasetId } : {}); export const getBenchmark = () => get('/api/benchmark/techniques'); export const getProcessUnits = () => get('/api/labeleval/process-units'); export const getAlerts = () => get<{ alerts: Alert[] }>('/api/labeleval/alerts'); export const getAudit = (limit = 200) => get<{ events: AuditEvent[] }>(`/api/labeleval/audit?limit=${limit}`); export const copilotExplain = (body: CopilotExplainRequest) => post('/api/copilot/explain', body); // ---------------------------------------------------------------- polling hook export interface PollState { data: T | null; loading: boolean; error: string | null; refresh: () => void; } /** * Simple poll-based data fetching. Pass intervalMs = null to fetch once. * `deps` re-triggers the initial fetch (with loading state) when changed. */ export function usePoll( fetcher: () => Promise, intervalMs: number | null, deps: ReadonlyArray = [] ): PollState { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tick, setTick] = useState(0); const fetcherRef = useRef(fetcher); fetcherRef.current = fetcher; useEffect(() => { let cancelled = false; let timer: ReturnType | null = null; setLoading(true); const run = async () => { try { const result = await fetcherRef.current(); if (!cancelled) { setData(result); setError(null); } } catch (err) { if (!cancelled) setError(err instanceof Error ? err.message : String(err)); } finally { if (!cancelled) { setLoading(false); if (intervalMs !== null) timer = setTimeout(run, intervalMs); } } }; void run(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [intervalMs, tick, ...deps]); const refresh = useCallback(() => setTick((t) => t + 1), []); return { data, loading, error, refresh }; } // ---------------------------------------------------------------- SSE stream hook /** * Subscribes to /api/events/stream via EventSource. If the stream errors, * falls back to polling GET /api/labeleval/pipeline every 2s (synthesizing a * StreamEvent so consumers see a single shape). */ export function useStream(): StreamEvent | null { const [event, setEvent] = useState(null); useEffect(() => { let disposed = false; let es: EventSource | null = null; let pollTimer: ReturnType | null = null; let lastAlerts = 0; const startPolling = () => { if (pollTimer || disposed) return; const poll = async () => { try { const pipeline = await getPipeline(); try { lastAlerts = (await getAlerts()).alerts.length; } catch { /* keep previous alert count */ } let megaeval: MegaevalStatus | undefined; try { megaeval = await getMegaevalStatus(); } catch { /* megaeval layer unavailable — leave the field absent */ } if (!disposed) { setEvent({ ts: new Date().toISOString(), pipeline, training: null, alerts_count: lastAlerts, ...(megaeval ? { megaeval } : {}), }); } } catch { /* backend offline — keep last known state */ } }; void poll(); pollTimer = setInterval(() => void poll(), 2000); }; try { es = new EventSource('/api/events/stream'); es.onmessage = (msg: MessageEvent) => { try { setEvent(JSON.parse(msg.data) as StreamEvent); } catch { /* ignore malformed event */ } }; es.onerror = () => { if (es) { es.close(); es = null; } startPolling(); }; } catch { startPolling(); } return () => { disposed = true; if (es) es.close(); if (pollTimer) clearInterval(pollTimer); }; }, []); return event; }