MAESTRO: add report container summary and filtering

This commit is contained in:
Mariusz Banach
2026-02-18 03:03:36 +01:00
parent 734692972a
commit ba6831d08b
3 changed files with 250 additions and 6 deletions

View 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>
);
}

View File

@@ -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>