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:
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";
|
||||
|
||||
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(
|
||||
<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 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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
Header
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
{result.description ? (
|
||||
<p className="mt-1 text-xs text-text/50">{result.description}</p>
|
||||
|
||||
Reference in New Issue
Block a user