diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2d327482..d20131e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import Toolkit from './pages/Toolkit' import ToolConfig from './pages/ToolConfig' import Findings from './pages/Findings' import Reports from './pages/Reports' +import ReportCompare from './pages/ReportCompare' import Settings from './pages/Settings' import Scans from './pages/Scans' import TaskDetails from './pages/TaskDetails' @@ -27,6 +28,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/ReportCompare.tsx b/frontend/src/pages/ReportCompare.tsx new file mode 100644 index 00000000..ffac3142 --- /dev/null +++ b/frontend/src/pages/ReportCompare.tsx @@ -0,0 +1,350 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { motion } from 'framer-motion' +import { HugeiconsIcon } from '@hugeicons/react' +import { + ArrowLeft02Icon, + Analytics02Icon, + Refresh01Icon, +} from '@hugeicons/core-free-icons' +import { getFindings, getReports } from '../api' +import { routes } from '../routes' +import { formatDateLong } from '../utils/date' +import { + compareFindings, + type ComparableFinding, + type ComparedFinding, + type ReportComparisonResult, +} from '../utils/compareFindings' + +type ReportOption = { + id: string + task_id: string + name: string + generated_at: string + findings: number + status: string +} + +function reportHasFindings( + report: ReportOption, + findingsByTask: Record, +): boolean { + const fromApi = Number(report.findings) + if (fromApi > 0) return true + return (findingsByTask[report.task_id]?.length ?? 0) > 0 +} + +/** Compare uses finding diffs; include ready reports and failed scans that still produced findings. */ +function comparableReports( + rows: ReportOption[], + findingsByTask: Record, +): ReportOption[] { + return rows.filter( + (r) => r.status === 'ready' || (r.status === 'failed' && reportHasFindings(r, findingsByTask)), + ) +} + +function reportOptionLabel(report: ReportOption): string { + const statusNote = report.status === 'failed' ? ' (scan failed)' : '' + return `${report.name}${statusNote} — ${formatDateLong(report.generated_at)}` +} + +const severityChip: Record = { + critical: 'bg-rag-red text-black', + high: 'bg-rag-amber text-black', + medium: 'bg-rag-blue text-black', + low: 'bg-charcoal-dark text-silver-bright border border-silver-bright/15', + info: 'bg-charcoal-dark text-silver border border-silver/15', +} + +function toComparableFinding(raw: Record): ComparableFinding | null { + const title = typeof raw.title === 'string' ? raw.title : '' + const target = typeof raw.target === 'string' ? raw.target : '' + const category = typeof raw.category === 'string' ? raw.category : '' + const severity = typeof raw.severity === 'string' ? raw.severity : 'info' + if (!title && !target) return null + return { + id: typeof raw.id === 'string' ? raw.id : undefined, + title: title || 'Untitled finding', + target: target || 'Unknown target', + category: category || 'General', + severity, + description: typeof raw.description === 'string' ? raw.description : undefined, + } +} + +function FindingRow({ item, showBaseline, showComparison }: { + item: ComparedFinding + showBaseline?: boolean + showComparison?: boolean +}) { + const finding = (showComparison ? item.comparison : item.baseline) ?? item.comparison ?? item.baseline + if (!finding) return null + const severity = (finding.severity || 'info').toLowerCase() + const chip = severityChip[severity] ?? severityChip.info + + return ( +
+
+ + {severity} + + {finding.category} +
+

{finding.title}

+

{finding.target}

+ {showBaseline && showComparison && item.baseline && item.comparison && ( +

+ {item.baseline.severity} → {item.comparison.severity} +

+ )} +
+ ) +} + +function CompareSection({ + title, + items, + tone, + showBaseline, + showComparison, +}: { + title: string + items: ComparedFinding[] + tone: string + showBaseline?: boolean + showComparison?: boolean +}) { + return ( +
+
+

{title}

+ {items.length} +
+ {items.length === 0 ? ( +

None

+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ) +} + +export default function ReportCompare() { + const [reports, setReports] = useState([]) + const [findingsByTask, setFindingsByTask] = useState>({}) + const [baselineReportId, setBaselineReportId] = useState('') + const [comparisonReportId, setComparisonReportId] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const loadData = () => { + setLoading(true) + setError(null) + Promise.all([getReports(), getFindings()]) + .then((results) => { + const reportData = results[0] as { reports?: ReportOption[] } + const findingsData = results[1] as { findings?: Record[] } + + const byTask: Record = {} + for (const raw of findingsData.findings || []) { + const row = raw as Record + const finding = toComparableFinding(row) + if (!finding) continue + const taskId = typeof row.task_id === 'string' ? row.task_id : '' + if (taskId) { + if (!byTask[taskId]) byTask[taskId] = [] + byTask[taskId].push(finding) + } + } + setFindingsByTask(byTask) + + const rawReports = (reportData.reports || []).map((r) => ({ + ...r, + findings: Number(r.findings ?? 0), + })) + setReports(comparableReports(rawReports, byTask)) + }) + .catch(() => setError('Failed to load reports or findings')) + .finally(() => setLoading(false)) + } + + useEffect(() => { + loadData() + }, []) + + const baselineReport = reports.find((r) => r.id === baselineReportId) + const comparisonReport = reports.find((r) => r.id === comparisonReportId) + + const comparison: ReportComparisonResult | null = useMemo(() => { + if (!baselineReport || !comparisonReport) return null + if (baselineReport.id === comparisonReport.id) return null + const baselineFindings = findingsByTask[baselineReport.task_id] || [] + const comparisonFindings = findingsByTask[comparisonReport.task_id] || [] + return compareFindings(baselineFindings, comparisonFindings) + }, [baselineReport, comparisonReport, findingsByTask]) + + const sameReportSelected = Boolean( + baselineReportId && comparisonReportId && baselineReportId === comparisonReportId, + ) + + return ( +
+
+
+ + + Back to reports + +
+ Report_Diff v1.0 +
+

+ Compare Reports +

+

+ BASELINE_VS_COMPARISON // NEW_FIXED_UNCHANGED_SEVERITY +

+
+ +
+ + {loading && ( +

+ Loading comparison data... +

+ )} + + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && !error && ( + <> +
+ + +
+ + {reports.length < 2 && ( +

+ At least two reports with findings are required to compare. Run scans that finish + with results, then refresh. +

+ )} + + {sameReportSelected && ( +

+ Select two different reports to compare. +

+ )} + + {comparison && ( + +
+ +
+

Diff ready

+

+ {baselineReport?.name} → {comparisonReport?.name} +

+
+
+ +
+ + + + +
+
+ )} + + {!comparison && baselineReportId && comparisonReportId && !sameReportSelected && ( +

+ No findings to compare for the selected reports. +

+ )} + + )} +
+ ) +} diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 7464dbde..2ece7d0e 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { useNavigate } from 'react-router-dom' +import { useNavigate, Link } from 'react-router-dom' +import { routes } from '../routes' import { HugeiconsIcon } from '@hugeicons/react' import { Analytics02Icon, @@ -63,6 +64,29 @@ function ReportIcon({ return } +function normalizeReport(raw: Partial & Record): Report { + const findings = Number(raw.findings ?? 0) + const pages = Number(raw.pages ?? 0) + const assets = Number(raw.assets ?? 0) + const type = raw.type === 'executive' || raw.type === 'compliance' ? raw.type : 'technical' + const status = + raw.status === 'ready' || raw.status === 'generating' || raw.status === 'failed' + ? raw.status + : 'ready' + + return { + id: String(raw.id ?? ''), + task_id: String(raw.task_id ?? ''), + name: String(raw.name ?? 'Untitled report'), + type, + generated_at: String(raw.generated_at ?? ''), + status, + findings: Number.isFinite(findings) ? findings : 0, + assets: Number.isFinite(assets) ? assets : 0, + pages: Number.isFinite(pages) ? pages : 0, + } +} + export default function Reports() { const navigate = useNavigate() const [reports, setReports] = useState([]) @@ -82,7 +106,8 @@ export default function Reports() { setError(null) Promise.all([getReports(), getDashboardSummary()]) .then(([reportData, summaryData]: any) => { - setReports(reportData.reports || []) + const rows = Array.isArray(reportData?.reports) ? reportData.reports : [] + setReports(rows.map((row: Record) => normalizeReport(row))) setSummary(summaryData || {}) }) .catch(() => { @@ -122,6 +147,12 @@ export default function Reports() {
+ + Compare Reports +