mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 13:33:30 +01:00
MAESTRO: add ProgressIndicator component
This commit is contained in:
@@ -35,7 +35,7 @@ Frontend uses `fetch` with `ReadableStream` reader (not native `EventSource`, wh
|
|||||||
- [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)
|
||||||
- [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)
|
- [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)
|
- [x] 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
|
||||||
|
|
||||||
|
|||||||
146
frontend/src/components/ProgressIndicator.tsx
Normal file
146
frontend/src/components/ProgressIndicator.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
import type { AnalysisProgress } from "../types/analysis";
|
||||||
|
import type { AnalysisStatus } from "../hooks/useAnalysis";
|
||||||
|
|
||||||
|
type ProgressIndicatorProps = {
|
||||||
|
status: AnalysisStatus;
|
||||||
|
progress: AnalysisProgress | null;
|
||||||
|
timeoutSeconds: number;
|
||||||
|
incompleteTests?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProgressVariant = "normal" | "warning" | "timeout";
|
||||||
|
|
||||||
|
const formatSeconds = (seconds: number): string => `${seconds}s`;
|
||||||
|
|
||||||
|
const getVariant = (
|
||||||
|
status: AnalysisStatus,
|
||||||
|
remainingSeconds: number,
|
||||||
|
timeoutSeconds: number,
|
||||||
|
): ProgressVariant => {
|
||||||
|
if (status === "timeout") {
|
||||||
|
return "timeout";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== "analysing") {
|
||||||
|
return "normal";
|
||||||
|
}
|
||||||
|
|
||||||
|
const warningThreshold = Math.max(5, Math.round(timeoutSeconds * 0.15));
|
||||||
|
return remainingSeconds <= warningThreshold ? "warning" : "normal";
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantStyles: Record<ProgressVariant, {
|
||||||
|
bar: string;
|
||||||
|
badge: string;
|
||||||
|
track: string;
|
||||||
|
}> = {
|
||||||
|
normal: {
|
||||||
|
bar: "bg-clean",
|
||||||
|
badge: "text-clean border-clean/40",
|
||||||
|
track: "bg-clean/10",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bar: "bg-suspicious",
|
||||||
|
badge: "text-suspicious border-suspicious/40",
|
||||||
|
track: "bg-suspicious/10",
|
||||||
|
},
|
||||||
|
timeout: {
|
||||||
|
bar: "bg-spam",
|
||||||
|
badge: "text-spam border-spam/40",
|
||||||
|
track: "bg-spam/10",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProgressIndicator({
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
timeoutSeconds,
|
||||||
|
incompleteTests = [],
|
||||||
|
}: ProgressIndicatorProps) {
|
||||||
|
const elapsedSeconds = progress ? Math.floor(progress.elapsedMs / 1000) : 0;
|
||||||
|
const remainingSeconds = Math.max(0, timeoutSeconds - elapsedSeconds);
|
||||||
|
const percentage = progress ? Math.round(progress.percentage) : 0;
|
||||||
|
const variant = getVariant(status, remainingSeconds, timeoutSeconds);
|
||||||
|
const styles = variantStyles[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
data-testid="progress-indicator"
|
||||||
|
data-status={status}
|
||||||
|
data-variant={variant}
|
||||||
|
className="rounded-2xl border border-info/10 bg-surface p-6 shadow-[0_0_40px_rgba(15,23,42,0.35)]"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] uppercase tracking-[0.2em] ${styles.badge}`}
|
||||||
|
>
|
||||||
|
{status === "analysing" ? "Analysing" : status}
|
||||||
|
</span>
|
||||||
|
{status === "analysing" ? (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faSpinner}
|
||||||
|
spin
|
||||||
|
data-testid="progress-spinner"
|
||||||
|
className="text-info"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-text/60">
|
||||||
|
<span data-testid="progress-elapsed">{formatSeconds(elapsedSeconds)}</span>
|
||||||
|
<span className="text-text/30">/</span>
|
||||||
|
<span data-testid="progress-remaining">{formatSeconds(remainingSeconds)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
data-testid="progress-current-test"
|
||||||
|
className="text-sm font-semibold text-text/80"
|
||||||
|
>
|
||||||
|
{progress?.currentTest ?? "Preparing analysis"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-testid="progress-percentage"
|
||||||
|
className="text-sm font-semibold text-text/70"
|
||||||
|
>
|
||||||
|
{percentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={percentage}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
className={`h-2 w-full overflow-hidden rounded-full ${styles.track}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all ${styles.bar}`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "timeout" ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-xl border border-spam/30 bg-spam/10 p-4 text-xs text-text/80"
|
||||||
|
>
|
||||||
|
<p className="font-semibold text-spam">
|
||||||
|
Timeout reached at {timeoutSeconds} seconds.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-text/70">Incomplete tests:</p>
|
||||||
|
<p data-testid="timeout-tests" className="mt-1 font-mono text-text/70">
|
||||||
|
{incompleteTests.length > 0 ? incompleteTests.join(", ") : "None"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user