mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 05:23:31 +01:00
MAESTRO: add inline error indicators to results
This commit is contained in:
@@ -44,7 +44,7 @@ Frontend uses `fetch` with `ReadableStream` reader (not native `EventSource`, wh
|
|||||||
- [x] Submitting headers triggers backend analysis with SSE streaming
|
- [x] Submitting headers triggers backend analysis with SSE streaming
|
||||||
- [x] Progress bar updates in real-time showing current test name and percentage
|
- [x] Progress bar updates in real-time showing current test name and percentage
|
||||||
- [x] Countdown timer counts down from 30 seconds
|
- [x] Countdown timer counts down from 30 seconds
|
||||||
- [ ] Partial failures show inline error indicators per FR-25
|
- [x] Partial failures show inline error indicators per FR-25 (Added AnalysisResults rendering with inline error badges.)
|
||||||
- [ ] Timeout at 30s displays partial results with notification listing incomplete tests
|
- [ ] Timeout at 30s displays partial results with notification listing incomplete tests
|
||||||
- [ ] Empty input returns 400, oversized >1MB returns 413
|
- [ ] Empty input returns 400, oversized >1MB returns 413
|
||||||
- [ ] Linting passes on both sides
|
- [ ] Linting passes on both sides
|
||||||
|
|||||||
105
frontend/src/__tests__/AnalysisResults.test.tsx
Normal file
105
frontend/src/__tests__/AnalysisResults.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { act } from "react-dom/test-utils";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import AnalysisResults from "../components/AnalysisResults";
|
||||||
|
import type { AnalysisReport } from "../types/analysis";
|
||||||
|
|
||||||
|
type RenderResult = {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
const render = (ui: ReactElement): RenderResult => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanups.push(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { container };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getByTestId = (container: HTMLElement, testId: string): HTMLElement => {
|
||||||
|
const element = container.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Expected element ${testId} to be rendered.`);
|
||||||
|
}
|
||||||
|
return element as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryByTestId = (container: HTMLElement, testId: string): HTMLElement | null =>
|
||||||
|
container.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
|
||||||
|
const report: AnalysisReport = {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
testId: 101,
|
||||||
|
testName: "SpamAssassin Rule Hits",
|
||||||
|
headerName: "X-Spam-Flag",
|
||||||
|
headerValue: "YES",
|
||||||
|
analysis: "Flagged by local rules.",
|
||||||
|
description: "SpamAssassin rules matched during analysis.",
|
||||||
|
severity: "spam",
|
||||||
|
status: "error",
|
||||||
|
error: "SpamAssassin database timeout.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testId: 202,
|
||||||
|
testName: "Mimecast Fingerprint",
|
||||||
|
headerName: "X-Mimecast-Spam-Info",
|
||||||
|
headerValue: "none",
|
||||||
|
analysis: "No fingerprint detected.",
|
||||||
|
description: "No known fingerprint found.",
|
||||||
|
severity: "clean",
|
||||||
|
status: "success",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hopChain: [],
|
||||||
|
securityAppliances: [],
|
||||||
|
metadata: {
|
||||||
|
totalTests: 2,
|
||||||
|
passedTests: 1,
|
||||||
|
failedTests: 1,
|
||||||
|
skippedTests: 0,
|
||||||
|
elapsedMs: 2200,
|
||||||
|
timedOut: false,
|
||||||
|
incompleteTests: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanups.length > 0) {
|
||||||
|
const cleanup = cleanups.pop();
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AnalysisResults", () => {
|
||||||
|
it("shows inline error indicators for failed tests", () => {
|
||||||
|
const { container } = render(<AnalysisResults report={report} />);
|
||||||
|
|
||||||
|
const errorIndicator = getByTestId(container, "analysis-error-101");
|
||||||
|
expect(errorIndicator.textContent ?? "").toMatch(/SpamAssassin database timeout/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render error indicators for successful tests", () => {
|
||||||
|
const { container } = render(<AnalysisResults report={report} />);
|
||||||
|
|
||||||
|
expect(queryByTestId(container, "analysis-error-202")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import AnalyseButton from "../components/AnalyseButton";
|
import AnalyseButton from "../components/AnalyseButton";
|
||||||
|
import AnalysisResults from "../components/AnalysisResults";
|
||||||
import FileDropZone from "../components/FileDropZone";
|
import FileDropZone from "../components/FileDropZone";
|
||||||
import HeaderInput from "../components/HeaderInput";
|
import HeaderInput from "../components/HeaderInput";
|
||||||
import ProgressIndicator from "../components/ProgressIndicator";
|
import ProgressIndicator from "../components/ProgressIndicator";
|
||||||
@@ -18,7 +19,7 @@ const defaultConfig: AnalysisConfig = {
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [headerInput, setHeaderInput] = useState("");
|
const [headerInput, setHeaderInput] = useState("");
|
||||||
const { status, progress, submit } = useAnalysis();
|
const { status, progress, result, submit } = useAnalysis();
|
||||||
const hasHeaderInput = headerInput.trim().length > 0;
|
const hasHeaderInput = headerInput.trim().length > 0;
|
||||||
const isOversized = headerInput.length > MAX_HEADER_INPUT_BYTES;
|
const isOversized = headerInput.length > MAX_HEADER_INPUT_BYTES;
|
||||||
const canAnalyse = hasHeaderInput && !isOversized;
|
const canAnalyse = hasHeaderInput && !isOversized;
|
||||||
@@ -89,6 +90,10 @@ export default function Home() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<AnalysisResults report={result} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
116
frontend/src/components/AnalysisResults.tsx
Normal file
116
frontend/src/components/AnalysisResults.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AnalysisReport,
|
||||||
|
TestResult,
|
||||||
|
TestSeverity,
|
||||||
|
TestStatus,
|
||||||
|
} from "../types/analysis";
|
||||||
|
|
||||||
|
type AnalysisResultsProps = {
|
||||||
|
report: AnalysisReport;
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityStyles: Record<
|
||||||
|
TestSeverity,
|
||||||
|
{ label: string; className: string }
|
||||||
|
> = {
|
||||||
|
spam: { label: "Spam", className: "text-spam border-spam/40" },
|
||||||
|
suspicious: { label: "Suspicious", className: "text-suspicious border-suspicious/40" },
|
||||||
|
clean: { label: "Clean", className: "text-clean border-clean/40" },
|
||||||
|
info: { label: "Info", className: "text-accent border-accent/40" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusStyles: Record<
|
||||||
|
TestStatus,
|
||||||
|
{ label: string; className: string }
|
||||||
|
> = {
|
||||||
|
success: { label: "Success", className: "text-clean border-clean/40" },
|
||||||
|
error: { label: "Error", className: "text-spam border-spam/40" },
|
||||||
|
skipped: { label: "Skipped", className: "text-suspicious border-suspicious/40" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorMessage = (result: TestResult): string => {
|
||||||
|
const message = result.error?.trim();
|
||||||
|
return message && message.length > 0 ? message : "Unknown failure.";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AnalysisResults({ report }: AnalysisResultsProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
data-testid="analysis-results"
|
||||||
|
className="rounded-2xl border border-info/10 bg-surface p-6 shadow-[0_0_40px_rgba(15,23,42,0.25)]"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<span className="text-xs uppercase tracking-[0.2em] text-info/90">
|
||||||
|
Analysis Report
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-text/50">
|
||||||
|
{report.metadata.passedTests} passed / {report.metadata.failedTests} failed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-4 space-y-4">
|
||||||
|
{report.results.map((result) => {
|
||||||
|
const statusStyle = statusStyles[result.status];
|
||||||
|
const severityStyle = severityStyles[result.severity];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={result.testId}
|
||||||
|
data-testid={`analysis-result-${result.testId}`}
|
||||||
|
className="rounded-2xl border border-info/10 bg-background/40 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold text-text/90">
|
||||||
|
{result.testName}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-text/50">
|
||||||
|
Test #{result.testId} · {result.headerName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-[0.2em]">
|
||||||
|
<span
|
||||||
|
className={`rounded-full border px-2 py-1 ${statusStyle.className}`}
|
||||||
|
>
|
||||||
|
{statusStyle.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full border px-2 py-1 ${severityStyle.className}`}
|
||||||
|
>
|
||||||
|
{severityStyle.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.analysis ? (
|
||||||
|
<p className="mt-3 text-sm text-text/70">{result.analysis}</p>
|
||||||
|
) : null}
|
||||||
|
{result.description ? (
|
||||||
|
<p className="mt-1 text-xs text-text/50">{result.description}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{result.status === "error" ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-testid={`analysis-error-${result.testId}`}
|
||||||
|
className="mt-3 flex items-start gap-2 rounded-xl border border-spam/30 bg-spam/10 p-3 text-xs text-spam"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTriangleExclamation} className="mt-0.5" />
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold">Error:</span>{" "}
|
||||||
|
{getErrorMessage(result)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user