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 c1f9a19..a4d822e 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 @@ -42,7 +42,7 @@ ReportContainer - [x] T031 [P] [US4] Create `frontend/src/components/report/TestResultCard.tsx` — collapsible card per test result. Severity-coloured indicator (red=spam, amber=suspicious, green=clean per FR-09), header name, monospace value, analysis text. Failed tests show error indicator (FR-25). Expand/collapse with animation, keyboard accessible (NFR-02). Verify `TestResultCard.test.tsx` passes (TDD Green) - [x] T032 [P] [US4] Create `frontend/src/components/report/HopChainVisualisation.tsx` — vertical flow diagram of mail server hop chain (FR-08): hostname, IP, timestamp, server version, connecting arrows. FontAwesome server/network icons. Responsive. Verify `HopChainVisualisation.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) -- [ ] 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) - [ ] 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) diff --git a/frontend/src/components/report/ReportExport.tsx b/frontend/src/components/report/ReportExport.tsx new file mode 100644 index 0000000..f8fe857 --- /dev/null +++ b/frontend/src/components/report/ReportExport.tsx @@ -0,0 +1,479 @@ +"use client"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faDownload, faFileCode } from "@fortawesome/free-solid-svg-icons"; + +import type { AnalysisReport, TestResult, TestSeverity } from "../../types/analysis"; + +type ReportExportProps = { + report: AnalysisReport; +}; + +const severityStyles: Record = { + spam: { label: "Spam", color: "#ff5555" }, + suspicious: { label: "Suspicious", color: "#ffb86c" }, + clean: { label: "Clean", color: "#50fa7b" }, + info: { label: "Info", color: "#bd93f9" }, +}; + +const escapeHtml = (value: string): string => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); + +const getErrorMessage = (result: TestResult): string => { + const message = result.error?.trim(); + return message && message.length > 0 ? message : "Unknown failure."; +}; + +const formatElapsed = (elapsedMs: number): string => { + if (Number.isNaN(elapsedMs)) { + return "-"; + } + return `${(elapsedMs / 1000).toFixed(2)}s`; +}; + +const countBySeverity = (results: TestResult[]): Record => + results.reduce( + (accumulator, result) => { + accumulator[result.severity] += 1; + return accumulator; + }, + { spam: 0, suspicious: 0, clean: 0, info: 0 }, + ); + +const buildHtmlReport = (report: AnalysisReport): string => { + const severityCounts = countBySeverity(report.results); + const generatedAt = new Date().toISOString(); + + const renderResult = (result: TestResult): string => { + const severity = severityStyles[result.severity]; + + return ` +
+
+
+ + ${severity.label} + + ${escapeHtml(result.testName)} + Test #${result.testId} +
+ + ${escapeHtml(result.status)} + +
+
+
+ Header + ${escapeHtml(result.headerName)} +
${escapeHtml(result.headerValue)}
+
+ ${result.analysis ? `

${escapeHtml(result.analysis)}

` : ""} + ${result.description ? `

${escapeHtml(result.description)}

` : ""} + ${ + result.status === "error" + ? `
Error: ${escapeHtml(getErrorMessage(result))}
` + : "" + } +
+
+ `; + }; + + const renderHopChain = (): string => { + if (report.hopChain.length === 0) { + return "

No hop chain data available.

"; + } + + const items = report.hopChain + .map((node) => { + const parts = [ + escapeHtml(node.hostname), + node.ip ? `${escapeHtml(node.ip)}` : "", + node.timestamp ? `${escapeHtml(node.timestamp)}` : "", + node.serverInfo ? `${escapeHtml(node.serverInfo)}` : "", + ] + .filter((part) => part.length > 0) + .join(" · "); + return `
  • ${parts}
  • `; + }) + .join(""); + + return `
      ${items}
    `; + }; + + const renderSecurityAppliances = (): string => { + if (report.securityAppliances.length === 0) { + return "

    No security appliances detected.

    "; + } + + const items = report.securityAppliances + .map((appliance) => { + return `${escapeHtml(appliance.name)}`; + }) + .join(""); + + return `
    ${items}
    `; + }; + + return ` + + + + + Email Header Analysis Report + + + +
    +
    +

    Email Header Analysis Report

    +

    Generated at ${escapeHtml(generatedAt)}

    +
    + +
    +

    Summary

    +
    +
    + Total tests + ${report.metadata.totalTests} +
    +
    + Passed + ${report.metadata.passedTests} +
    +
    + Failed + ${report.metadata.failedTests} +
    +
    + Skipped + ${report.metadata.skippedTests} +
    +
    + Elapsed + ${formatElapsed(report.metadata.elapsedMs)} +
    +
    + Spam + ${severityCounts.spam} +
    +
    + Suspicious + ${severityCounts.suspicious} +
    +
    + Clean + ${severityCounts.clean} +
    +
    + Info + ${severityCounts.info} +
    +
    +
    + +
    +

    Security Appliances

    + ${renderSecurityAppliances()} +
    + +
    +

    Hop Chain

    + ${renderHopChain()} +
    + +
    +

    Test Results

    + ${report.results.map(renderResult).join("")} +
    +
    + +`; +}; + +const downloadBlob = (content: string, mimeType: string, fileName: string) => { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + anchor.rel = "noopener"; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +}; + +export default function ReportExport({ report }: ReportExportProps) { + const handleJsonExport = () => { + downloadBlob(JSON.stringify(report, null, 2), "application/json", "analysis-report.json"); + }; + + const handleHtmlExport = () => { + downloadBlob(buildHtmlReport(report), "text/html", "analysis-report.html"); + }; + + return ( +
    +
    +
    +

    Export Report

    +

    + Download a standalone HTML snapshot or raw JSON data. +

    +
    +
    + + +
    +
    +
    + ); +}