diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-06-Interactive-Report.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-06-Interactive-Report.md index 1d50b25..8219348 100644 --- a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-06-Interactive-Report.md +++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-06-Interactive-Report.md @@ -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] 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) -- [ ] 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 diff --git a/frontend/src/components/report/ReportContainer.tsx b/frontend/src/components/report/ReportContainer.tsx new file mode 100644 index 0000000..d270ec2 --- /dev/null +++ b/frontend/src/components/report/ReportContainer.tsx @@ -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 => + 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 ( +
+
+
+

+ Interactive Report +

+

Header Analysis Summary

+
+
+ {report.metadata.passedTests} passed · {report.metadata.failedTests} failed +
+
+ +
+
+

Summary Stats

+ + Totals + +
+
+ {summaryItems.map((item) => ( +
+
+ + {item.label} +
+
{item.value}
+
+ ))} +
+
+ + + + + + + + + +
+
+
+

Test Results

+

+ Detailed analysis per header test with severity context. +

+
+ + {filteredResults.length} / {report.results.length} + +
+ +
+ {filteredResults.map((result) => ( +
+ +
+ ))} + {filteredResults.length === 0 ? ( +

No results match the current search.

+ ) : null} +
+
+
+ ); +} diff --git a/frontend/src/components/report/TestResultCard.tsx b/frontend/src/components/report/TestResultCard.tsx index dbd7550..11dd3a5 100644 --- a/frontend/src/components/report/TestResultCard.tsx +++ b/frontend/src/components/report/TestResultCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBan, @@ -33,9 +33,50 @@ const getErrorMessage = (result: TestResult): string => { type TestResultCardProps = { 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( + + {matchText} + , + ); + + 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 severityStyle = severityStyles[result.severity]; 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" >
- {result.testName} + + {highlightText(result.testName, highlightQuery)} + Test #{result.testId}
@@ -98,12 +141,16 @@ export default function TestResultCard({ result }: TestResultCardProps) { Header - {result.headerName} + + {highlightText(result.headerName, highlightQuery)} + {result.headerValue}
{result.analysis ? ( -

{result.analysis}

+

+ {highlightText(result.analysis, highlightQuery)} +

) : null} {result.description ? (

{result.description}