mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 13:33:30 +01:00
MAESTRO: implement analysis hook
This commit is contained in:
@@ -34,7 +34,7 @@ Frontend uses `fetch` with `ReadableStream` reader (not native `EventSource`, wh
|
|||||||
- [x] T025 [US3] Write failing tests (TDD Red) in `backend/tests/api/test_analysis_router.py` — happy path (valid headers → 200 with SSE progress + result), error path (empty → 400), oversized (>1MB → 413), partial failure (some tests error → mixed results per FR-25), timeout (30s limit per NFR-13, partial results per NFR-14)
|
- [x] T025 [US3] Write failing tests (TDD Red) in `backend/tests/api/test_analysis_router.py` — happy path (valid headers → 200 with SSE progress + result), error path (empty → 400), oversized (>1MB → 413), partial failure (some tests error → mixed results per FR-25), timeout (30s limit per NFR-13, partial results per NFR-14)
|
||||||
- [x] T026 [US3] Create `backend/app/schemas/analysis.py` (request/response schemas) and `backend/app/routers/analysis.py` — FastAPI router with `POST /api/analyse` using SSE for progress streaming. Accepts headers string + config (test IDs, resolve, decode-all). Invokes `HeaderAnalyzer` with 30s timeout (NFR-13). Streams progress events then final result. Sanitises input (NFR-09), validates size ≤1MB (NFR-10). Stateless — no job_id, no in-memory state (Assumption 3). Register router in `backend/app/main.py`. Verify `test_analysis_router.py` passes (TDD Green)
|
- [x] T026 [US3] Create `backend/app/schemas/analysis.py` (request/response schemas) and `backend/app/routers/analysis.py` — FastAPI router with `POST /api/analyse` using SSE for progress streaming. Accepts headers string + config (test IDs, resolve, decode-all). Invokes `HeaderAnalyzer` with 30s timeout (NFR-13). Streams progress events then final result. Sanitises input (NFR-09), validates size ≤1MB (NFR-10). Stateless — no job_id, no in-memory state (Assumption 3). Register router in `backend/app/main.py`. Verify `test_analysis_router.py` passes (TDD Green)
|
||||||
- [x] T027 [US3] Write failing tests (TDD Red) in `frontend/src/__tests__/ProgressIndicator.test.tsx` (render at various states, timeout display) and `frontend/src/__tests__/useAnalysis.test.ts` (hook state transitions, SSE handling)
|
- [x] T027 [US3] Write failing tests (TDD Red) in `frontend/src/__tests__/ProgressIndicator.test.tsx` (render at various states, timeout display) and `frontend/src/__tests__/useAnalysis.test.ts` (hook state transitions, SSE handling)
|
||||||
- [ ] T028 [P] [US3] Create `frontend/src/hooks/useAnalysis.ts` — custom hook managing analysis lifecycle. Submits to `POST /api/analyse` via API client, consumes SSE stream for real-time progress (no polling). States: idle, submitting, analysing (with progress), complete, error, timeout. Returns: `submit()`, `cancel()`, `progress`, `result`, `error`, `status`. Verify `useAnalysis.test.ts` passes (TDD Green)
|
- [x] T028 [P] [US3] Create `frontend/src/hooks/useAnalysis.ts` — custom hook managing analysis lifecycle. Submits to `POST /api/analyse` via API client, consumes SSE stream for real-time progress (no polling). States: idle, submitting, analysing (with progress), complete, error, timeout. Returns: `submit()`, `cancel()`, `progress`, `result`, `error`, `status`. Verify `useAnalysis.test.ts` passes (TDD Green)
|
||||||
- [ ] T029 [P] [US3] Create `frontend/src/components/ProgressIndicator.tsx` — progress bar with percentage, current test name (FR-22), countdown timer from 30s (NFR-13), elapsed time. Colour-coded: green progressing, amber near timeout, red on timeout. FontAwesome spinner. Timeout notification listing incomplete tests (NFR-14). Verify `ProgressIndicator.test.tsx` passes (TDD Green)
|
- [ ] T029 [P] [US3] Create `frontend/src/components/ProgressIndicator.tsx` — progress bar with percentage, current test name (FR-22), countdown timer from 30s (NFR-13), elapsed time. Colour-coded: green progressing, amber near timeout, red on timeout. FontAwesome spinner. Timeout notification listing incomplete tests (NFR-14). Verify `ProgressIndicator.test.tsx` passes (TDD Green)
|
||||||
|
|
||||||
## Completion
|
## Completion
|
||||||
|
|||||||
160
frontend/src/hooks/useAnalysis.ts
Normal file
160
frontend/src/hooks/useAnalysis.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
|
||||||
|
import { apiClient, type SseEvent } from "../lib/api-client";
|
||||||
|
import type { AnalysisConfig, AnalysisProgress, AnalysisReport } from "../types/analysis";
|
||||||
|
|
||||||
|
export type AnalysisStatus =
|
||||||
|
| "idle"
|
||||||
|
| "submitting"
|
||||||
|
| "analysing"
|
||||||
|
| "complete"
|
||||||
|
| "error"
|
||||||
|
| "timeout";
|
||||||
|
|
||||||
|
export interface AnalysisRequest {
|
||||||
|
headers: string;
|
||||||
|
config: AnalysisConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAnalysisState {
|
||||||
|
status: AnalysisStatus;
|
||||||
|
progress: AnalysisProgress | null;
|
||||||
|
result: AnalysisReport | null;
|
||||||
|
error: string | null;
|
||||||
|
submit: (request: AnalysisRequest) => Promise<void>;
|
||||||
|
cancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleTask = (handler: () => void): void => {
|
||||||
|
setTimeout(handler, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useAnalysis = (): UseAnalysisState => {
|
||||||
|
const [status, setStatus] = useState<AnalysisStatus>("idle");
|
||||||
|
const [progress, setProgress] = useState<AnalysisProgress | null>(null);
|
||||||
|
const [result, setResult] = useState<AnalysisReport | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
const inFlightRef = useRef(false);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const hasProgressRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setProgress(null);
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEvent = useCallback(
|
||||||
|
(
|
||||||
|
event: SseEvent<AnalysisProgress | AnalysisReport>,
|
||||||
|
requestId: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) => {
|
||||||
|
scheduleTask(() => {
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!inFlightRef.current || requestIdRef.current !== requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.event === "progress") {
|
||||||
|
const payload = event.data as AnalysisProgress;
|
||||||
|
if (!hasProgressRef.current) {
|
||||||
|
hasProgressRef.current = true;
|
||||||
|
flushSync(() => {
|
||||||
|
setProgress(payload);
|
||||||
|
setStatus("analysing");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProgress(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.event === "result") {
|
||||||
|
const report = event.data as AnalysisReport;
|
||||||
|
setResult(report);
|
||||||
|
inFlightRef.current = false;
|
||||||
|
setStatus(report.metadata.timedOut ? "timeout" : "complete");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = useCallback(
|
||||||
|
async (request: AnalysisRequest): Promise<void> => {
|
||||||
|
requestIdRef.current += 1;
|
||||||
|
const requestId = requestIdRef.current;
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
inFlightRef.current = true;
|
||||||
|
|
||||||
|
setStatus("submitting");
|
||||||
|
resetState();
|
||||||
|
hasProgressRef.current = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.stream<AnalysisRequest, AnalysisProgress | AnalysisReport>(
|
||||||
|
"/api/analyse",
|
||||||
|
{
|
||||||
|
body: request,
|
||||||
|
signal: controller.signal,
|
||||||
|
onEvent: (event) => handleEvent(event, requestId, controller.signal),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mountedRef.current || controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestIdRef.current !== requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlightRef.current = false;
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
setError(message);
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleEvent, resetState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
}
|
||||||
|
inFlightRef.current = false;
|
||||||
|
setStatus("idle");
|
||||||
|
resetState();
|
||||||
|
hasProgressRef.current = false;
|
||||||
|
}, [resetState]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
result,
|
||||||
|
error,
|
||||||
|
submit,
|
||||||
|
cancel,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAnalysis;
|
||||||
Reference in New Issue
Block a user