From 2ea67a374ba730bf4389e82affa8271003685a9b Mon Sep 17 00:00:00 2001 From: Pragati Date: Wed, 3 Jun 2026 17:40:25 +0530 Subject: [PATCH 1/2] feat(reports): add report comparison view (closes #36) --- frontend/src/App.tsx | 2 + frontend/src/pages/ReportCompare.tsx | 321 ++++++++++++++++++ frontend/src/pages/Reports.tsx | 9 +- frontend/src/routes.ts | 1 + frontend/src/utils/compareFindings.ts | 84 +++++ .../testing/unit/pages/ReportCompare.test.tsx | 95 ++++++ .../unit/utils/compareFindings.test.ts | 64 ++++ 7 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/ReportCompare.tsx create mode 100644 frontend/src/utils/compareFindings.ts create mode 100644 frontend/testing/unit/pages/ReportCompare.test.tsx create mode 100644 frontend/testing/unit/utils/compareFindings.test.ts 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..b66d32fd --- /dev/null +++ b/frontend/src/pages/ReportCompare.tsx @@ -0,0 +1,321 @@ +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 +} + +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 ready = (reportData.reports || []).filter((r) => r.status === 'ready') + setReports(ready) + + 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) + }) + .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 ready reports are required to compare. +

+ )} + + {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..d40e7bf6 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, @@ -122,6 +123,12 @@ export default function Reports() {
+ + Compare Reports +