mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 05:23:31 +01:00
MAESTRO: add report container summary and filtering
This commit is contained in:
@@ -44,7 +44,7 @@ ReportContainer
|
|||||||
- [x] T033 [P] [US4] Create `frontend/src/components/report/ReportSearchBar.tsx` — search/filter bar above report (FR-20). Filters by text match against test name, header name, or analysis text. Highlights matches, shows count. FontAwesome search icon, Escape to clear. Verify `ReportSearchBar.test.tsx` passes (TDD Green)
|
- [x] T033 [P] [US4] Create `frontend/src/components/report/ReportSearchBar.tsx` — search/filter bar above report (FR-20). Filters by text match against test name, header name, or analysis text. Highlights matches, shows count. FontAwesome search icon, Escape to clear. Verify `ReportSearchBar.test.tsx` passes (TDD Green)
|
||||||
- [x] T034 [P] [US4] Create `frontend/src/components/report/ReportExport.tsx` — export as HTML (styled standalone page) or JSON (raw data) per FR-21. FontAwesome download icons, triggers browser download. Verify `ReportExport.test.tsx` passes (TDD Green)
|
- [x] T034 [P] [US4] Create `frontend/src/components/report/ReportExport.tsx` — export as HTML (styled standalone page) or JSON (raw data) per FR-21. FontAwesome download icons, triggers browser download. Verify `ReportExport.test.tsx` passes (TDD Green)
|
||||||
- [x] T035 [US4] Create `frontend/src/components/report/SecurityAppliancesSummary.tsx` — summary listing detected email security products as badges/tags with FontAwesome shield icons. Handle empty state (no appliances detected). Verify `SecurityAppliancesSummary.test.tsx` passes (TDD Green)
|
- [x] T035 [US4] Create `frontend/src/components/report/SecurityAppliancesSummary.tsx` — summary listing detected email security products as badges/tags with FontAwesome shield icons. Handle empty state (no appliances detected). Verify `SecurityAppliancesSummary.test.tsx` passes (TDD Green)
|
||||||
- [ ] T036 [US4] Create `frontend/src/components/report/ReportContainer.tsx` — top-level wrapper receiving `AnalysisReport`. Renders: summary stats (total tests, passed, failed, severity breakdown), `TestResultCard` list, `HopChainVisualisation`, `SecurityAppliancesSummary`, `ReportSearchBar`, `ReportExport`. FontAwesome summary icons. Verify `ReportContainer.test.tsx` passes (TDD Green)
|
- [x] T036 [US4] Create `frontend/src/components/report/ReportContainer.tsx` — top-level wrapper receiving `AnalysisReport`. Renders: summary stats (total tests, passed, failed, severity breakdown), `TestResultCard` list, `HopChainVisualisation`, `SecurityAppliancesSummary`, `ReportSearchBar`, `ReportExport`. FontAwesome summary icons. Verify `ReportContainer.test.tsx` passes (TDD Green)
|
||||||
|
|
||||||
## Completion
|
## Completion
|
||||||
|
|
||||||
|
|||||||
197
frontend/src/components/report/ReportContainer.tsx
Normal file
197
frontend/src/components/report/ReportContainer.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import {
|
||||||
|
faBan,
|
||||||
|
faCheck,
|
||||||
|
faCircleInfo,
|
||||||
|
faListCheck,
|
||||||
|
faTriangleExclamation,
|
||||||
|
faXmark,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
import type { AnalysisReport, TestResult, TestSeverity } from "../../types/analysis";
|
||||||
|
import HopChainVisualisation from "./HopChainVisualisation";
|
||||||
|
import ReportExport from "./ReportExport";
|
||||||
|
import ReportSearchBar from "./ReportSearchBar";
|
||||||
|
import SecurityAppliancesSummary from "./SecurityAppliancesSummary";
|
||||||
|
import TestResultCard from "./TestResultCard";
|
||||||
|
|
||||||
|
type SummaryItem = {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
testId: string;
|
||||||
|
icon: typeof faCheck;
|
||||||
|
iconClassName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const countBySeverity = (results: TestResult[]): Record<TestSeverity, number> =>
|
||||||
|
results.reduce(
|
||||||
|
(accumulator, result) => {
|
||||||
|
accumulator[result.severity] += 1;
|
||||||
|
return accumulator;
|
||||||
|
},
|
||||||
|
{ spam: 0, suspicious: 0, clean: 0, info: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterResults = (query: string, results: TestResult[]): TestResult[] => {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.filter((result) => {
|
||||||
|
return (
|
||||||
|
result.testName.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
result.headerName.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
result.analysis.toLowerCase().includes(normalizedQuery)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReportContainer({ report }: { report: AnalysisReport }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const severityCounts = useMemo(() => countBySeverity(report.results), [report.results]);
|
||||||
|
const filteredResults = useMemo(
|
||||||
|
() => filterResults(query, report.results),
|
||||||
|
[query, report.results],
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryItems: SummaryItem[] = [
|
||||||
|
{
|
||||||
|
label: "Total Tests",
|
||||||
|
value: report.metadata.totalTests,
|
||||||
|
testId: "report-summary-total",
|
||||||
|
icon: faListCheck,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Passed",
|
||||||
|
value: report.metadata.passedTests,
|
||||||
|
testId: "report-summary-passed",
|
||||||
|
icon: faCheck,
|
||||||
|
iconClassName: "text-clean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Failed",
|
||||||
|
value: report.metadata.failedTests,
|
||||||
|
testId: "report-summary-failed",
|
||||||
|
icon: faXmark,
|
||||||
|
iconClassName: "text-spam",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Spam",
|
||||||
|
value: severityCounts.spam,
|
||||||
|
testId: "report-summary-severity-spam",
|
||||||
|
icon: faBan,
|
||||||
|
iconClassName: "text-spam",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Suspicious",
|
||||||
|
value: severityCounts.suspicious,
|
||||||
|
testId: "report-summary-severity-suspicious",
|
||||||
|
icon: faTriangleExclamation,
|
||||||
|
iconClassName: "text-suspicious",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Clean",
|
||||||
|
value: severityCounts.clean,
|
||||||
|
testId: "report-summary-severity-clean",
|
||||||
|
icon: faCheck,
|
||||||
|
iconClassName: "text-clean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Info",
|
||||||
|
value: severityCounts.info,
|
||||||
|
testId: "report-summary-severity-info",
|
||||||
|
icon: faCircleInfo,
|
||||||
|
iconClassName: "text-accent",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
data-testid="report-container"
|
||||||
|
className="flex flex-col gap-6 rounded-3xl border border-info/10 bg-surface/70 p-6 shadow-[0_0_50px_rgba(15,23,42,0.3)]"
|
||||||
|
>
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-info/80">
|
||||||
|
Interactive Report
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold text-text/90">Header Analysis Summary</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full border border-info/20 bg-background/40 px-4 py-2 text-xs text-text/60">
|
||||||
|
{report.metadata.passedTests} passed · {report.metadata.failedTests} failed
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-info/10 bg-background/40 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-text/90">Summary Stats</h3>
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.2em] text-text/40">
|
||||||
|
Totals
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{summaryItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.testId}
|
||||||
|
data-testid={item.testId}
|
||||||
|
className="rounded-xl border border-info/10 bg-surface/60 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.2em] text-text/50">
|
||||||
|
<FontAwesomeIcon icon={item.icon} className={`text-[10px] ${item.iconClassName}`} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-lg font-semibold text-text/90">{item.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ReportSearchBar
|
||||||
|
query={query}
|
||||||
|
matchCount={filteredResults.length}
|
||||||
|
totalCount={report.results.length}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReportExport report={report} />
|
||||||
|
|
||||||
|
<SecurityAppliancesSummary appliances={report.securityAppliances} />
|
||||||
|
|
||||||
|
<HopChainVisualisation hopChain={report.hopChain} />
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-info/10 bg-surface/50 p-4 shadow-[0_0_30px_rgba(15,23,42,0.18)]">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-text/90">Test Results</h3>
|
||||||
|
<p className="text-xs text-text/50">
|
||||||
|
Detailed analysis per header test with severity context.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-info/20 bg-background/40 px-3 py-2 text-[11px] font-mono text-text/60">
|
||||||
|
{filteredResults.length} / {report.results.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
{filteredResults.map((result) => (
|
||||||
|
<div
|
||||||
|
key={result.testId}
|
||||||
|
data-testid={`test-result-card-${result.testId}`}
|
||||||
|
>
|
||||||
|
<TestResultCard result={result} highlightQuery={query} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredResults.length === 0 ? (
|
||||||
|
<p className="text-sm text-text/60">No results match the current search.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import {
|
||||||
faBan,
|
faBan,
|
||||||
@@ -33,9 +33,50 @@ const getErrorMessage = (result: TestResult): string => {
|
|||||||
|
|
||||||
type TestResultCardProps = {
|
type TestResultCardProps = {
|
||||||
result: TestResult;
|
result: TestResult;
|
||||||
|
highlightQuery?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TestResultCard({ result }: TestResultCardProps) {
|
const highlightText = (text: string, query: string): ReactNode => {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedText = text.toLowerCase();
|
||||||
|
let startIndex = 0;
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
while (startIndex < text.length) {
|
||||||
|
const matchIndex = normalizedText.indexOf(normalizedQuery, startIndex);
|
||||||
|
if (matchIndex === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchIndex > startIndex) {
|
||||||
|
parts.push(text.slice(startIndex, matchIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchText = text.slice(matchIndex, matchIndex + normalizedQuery.length);
|
||||||
|
parts.push(
|
||||||
|
<mark
|
||||||
|
key={`${matchIndex}-${matchText}`}
|
||||||
|
className="rounded bg-accent/20 px-1 text-accent"
|
||||||
|
>
|
||||||
|
{matchText}
|
||||||
|
</mark>,
|
||||||
|
);
|
||||||
|
|
||||||
|
startIndex = matchIndex + normalizedQuery.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startIndex < text.length) {
|
||||||
|
parts.push(text.slice(startIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TestResultCard({ result, highlightQuery = "" }: TestResultCardProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const severityStyle = severityStyles[result.severity];
|
const severityStyle = severityStyles[result.severity];
|
||||||
const detailsId = `test-result-details-${result.testId}`;
|
const detailsId = `test-result-details-${result.testId}`;
|
||||||
@@ -66,7 +107,9 @@ export default function TestResultCard({ result }: TestResultCardProps) {
|
|||||||
className="flex w-full items-center justify-between gap-4 text-left"
|
className="flex w-full items-center justify-between gap-4 text-left"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-semibold text-text/90">{result.testName}</span>
|
<span className="text-sm font-semibold text-text/90">
|
||||||
|
{highlightText(result.testName, highlightQuery)}
|
||||||
|
</span>
|
||||||
<span className="text-xs text-text/50">Test #{result.testId}</span>
|
<span className="text-xs text-text/50">Test #{result.testId}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -98,12 +141,16 @@ export default function TestResultCard({ result }: TestResultCardProps) {
|
|||||||
<span className="text-[10px] uppercase tracking-[0.2em] text-text/40">
|
<span className="text-[10px] uppercase tracking-[0.2em] text-text/40">
|
||||||
Header
|
Header
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-text/60">{result.headerName}</span>
|
<span className="text-xs text-text/60">
|
||||||
|
{highlightText(result.headerName, highlightQuery)}
|
||||||
|
</span>
|
||||||
<span className="font-mono text-sm text-text/80">{result.headerValue}</span>
|
<span className="font-mono text-sm text-text/80">{result.headerValue}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result.analysis ? (
|
{result.analysis ? (
|
||||||
<p className="mt-3 text-sm text-text/70">{result.analysis}</p>
|
<p className="mt-3 text-sm text-text/70">
|
||||||
|
{highlightText(result.analysis, highlightQuery)}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{result.description ? (
|
{result.description ? (
|
||||||
<p className="mt-1 text-xs text-text/50">{result.description}</p>
|
<p className="mt-1 text-xs text-text/50">{result.description}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user