From 071aef96a1d7072f81ef098df4cba83cb2e134bb Mon Sep 17 00:00:00 2001 From: jramos Date: Fri, 13 Mar 2026 13:06:54 -0600 Subject: [PATCH] feat(reporting): Action Coverage chart + Archer Exception linking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace FP# Workflow chart with a 3-segment Action Coverage donut: - FP Request — finding has an Ivanti FP# workflow - Archer Exception — note matches EXC-\d+ pattern - Pending — no action taken yet Clicking a segment filters the findings table to that category with a colored badge in the action bar (click again or × to clear). Home page: each Archer ticket now has a filter icon button that navigates directly to the Reporting page pre-filtered to findings whose notes reference that EXC number. The EXC badge appears in the table action bar with a one-click clear. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.js | 22 +- .../src/components/pages/ReportingPage.js | 234 ++++++++++++------ 2 files changed, 177 insertions(+), 79 deletions(-) diff --git a/frontend/src/App.js b/frontend/src/App.js index b6c493d..4d8f369 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -179,6 +179,7 @@ export default function App() { const [currentPage, setCurrentPage] = useState('home'); const [navOpen, setNavOpen] = useState(false); const [calendarFilter, setCalendarFilter] = useState(null); + const [reportingExcFilter, setReportingExcFilter] = useState(null); const [showAddCVE, setShowAddCVE] = useState(false); const [showUserManagement, setShowUserManagement] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false); @@ -963,8 +964,8 @@ export default function App() { onClose={() => setNavOpen(false)} currentPage={currentPage} onNavigate={(page) => { - // Clear calendar filter when navigating directly via the nav drawer - if (page === 'reporting') setCalendarFilter(null); + // Clear contextual filters when navigating directly via the nav drawer + if (page === 'reporting') { setCalendarFilter(null); setReportingExcFilter(null); } setCurrentPage(page); }} /> @@ -1041,7 +1042,7 @@ export default function App() { {/* Page content */} - {currentPage === 'reporting' && } + {currentPage === 'reporting' && } {currentPage === 'knowledge-base' && } {currentPage === 'exports' && } @@ -2332,16 +2333,23 @@ export default function App() { > {ticket.exc_number} - {canWrite() && ( -
+
+ + {canWrite() && (<> -
- )} + )} +
{ticket.cve_id}
{ticket.vendor}
diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index e956ab4..e41e966 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -117,6 +117,17 @@ function getExportVal(finding, key) { } } +// --------------------------------------------------------------------------- +// Action coverage classification — used by chart and filter +// --------------------------------------------------------------------------- +const EXC_PATTERN = /EXC-\d+/i; + +function classifyFinding(finding) { + if (finding.workflow != null) return 'fp'; + if (EXC_PATTERN.test(finding.note || '')) return 'archer'; + return 'pending'; +} + // --------------------------------------------------------------------------- // Style helpers // --------------------------------------------------------------------------- @@ -265,19 +276,15 @@ function StatusDonut({ open, closed, loading }) { } // --------------------------------------------------------------------------- -// SVG Donut Chart — FP# workflow state distribution +// SVG Donut Chart — Action Coverage (FP Request | Archer Exception | Pending) // --------------------------------------------------------------------------- -const WF_STATE_DEFS = [ - { key: 'expired', label: 'Expired', color: '#EF4444' }, - { key: 'rejected', label: 'Rejected', color: '#F87171' }, - { key: 'reworked', label: 'Reworked', color: '#F59E0B' }, - { key: 'actionable', label: 'Actionable', color: '#FCD34D' }, - { key: 'requested', label: 'Requested', color: '#0EA5E9' }, - { key: 'approved', label: 'Approved', color: '#10B981' }, - { key: 'none', label: 'No FP#', color: '#334155' }, +const ACTION_DEFS = [ + { key: 'fp', label: 'FP Request', color: '#0EA5E9' }, + { key: 'archer', label: 'Archer Exception', color: '#F59E0B' }, + { key: 'pending', label: 'Pending', color: '#EF4444' }, ]; -function WorkflowDonut({ findings }) { +function ActionCoverageDonut({ findings, activeSegment, onSegmentClick }) { const SIZE = 180; const CX = SIZE / 2; const CY = SIZE / 2; @@ -285,16 +292,12 @@ function WorkflowDonut({ findings }) { const INNER = 48; const counts = useMemo(() => { - const map = Object.fromEntries(WF_STATE_DEFS.map((d) => [d.key, 0])); - findings.forEach((f) => { - const state = (f.workflow?.state || '').toLowerCase(); - if (state && state in map) map[state]++; - else map.none++; - }); + const map = { fp: 0, archer: 0, pending: 0 }; + findings.forEach((f) => { map[classifyFinding(f)]++; }); return map; }, [findings]); - const total = Object.values(counts).reduce((a, b) => a + b, 0); + const total = findings.length; if (total === 0) { return ( @@ -305,29 +308,35 @@ function WorkflowDonut({ findings }) { } let cursor = 0; - const segments = WF_STATE_DEFS - .map((def) => { - const count = counts[def.key]; - if (!count) return null; - const start = cursor; - const end = cursor + (count / total) * 360; - cursor = end; - return { ...def, count, start, end }; - }) - .filter(Boolean); + const segments = ACTION_DEFS.map((def) => { + const count = counts[def.key]; + const start = cursor; + const end = count > 0 ? cursor + (count / total) * 360 : cursor; + if (count > 0) cursor = end; + return { ...def, count, start, end }; + }); + + const hasActive = !!activeSegment; return (
- {segments.map((seg) => ( - - ))} + {segments.filter((s) => s.count > 0).map((seg) => { + const isActive = activeSegment === seg.key; + return ( + onSegmentClick(isActive ? null : seg.key)} + /> + ); + })} {total.toLocaleString()} @@ -336,24 +345,40 @@ function WorkflowDonut({ findings }) { - {/* Legend */} -
- {segments.map((seg) => ( -
-
-
- - {seg.label} - - - {seg.count} - - - ({((seg.count / total) * 100).toFixed(0)}%) - + {/* Legend — always shows all 3 categories */} +
+ {segments.map((seg) => { + const isActive = activeSegment === seg.key; + const dimmed = hasActive && !isActive; + return ( +
onSegmentClick(isActive ? null : seg.key)} + style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: dimmed ? 0.35 : 1, transition: 'opacity 0.2s' }} + > +
+
+ + {seg.label} + + + {seg.count} + + + ({total > 0 ? ((seg.count / total) * 100).toFixed(0) : 0}%) + +
-
- ))} + ); + })} + {hasActive && ( + + )}
); @@ -835,7 +860,7 @@ function TableCell({ colKey, finding }) { // --------------------------------------------------------------------------- // Main ReportingPage // --------------------------------------------------------------------------- -export default function ReportingPage({ filterDate }) { +export default function ReportingPage({ filterDate, filterEXC }) { const [findings, setFindings] = useState([]); const [total, setTotal] = useState(null); const [syncedAt, setSyncedAt] = useState(null); @@ -852,6 +877,8 @@ export default function ReportingPage({ filterDate }) { ); const [openFilter, setOpenFilter] = useState(null); const filterBtnRefs = useRef({}); + const [actionFilter, setActionFilter] = useState(null); + const [excFilter, setExcFilter] = useState(filterEXC || null); const updateColumns = useCallback((newOrder) => { setColumnOrder(newOrder); @@ -925,22 +952,38 @@ export default function ReportingPage({ filterDate }) { }); }, []); - // Apply all active column filters to produce the visible row set + // Apply all active filters to produce the visible row set const filtered = useMemo(() => { + let result = findings; + + // Column filters const active = Object.entries(columnFilters); - if (active.length === 0) return findings; - return findings.filter((f) => - active.every(([key, vals]) => { - if (!vals || vals.size === 0) return false; - const def = COLUMN_DEFS[key]; - if (def?.multiValue) { - // Row matches if ANY of its values is in the selected set - return (f[key] || []).some((v) => vals.has(String(v).trim())); - } - return vals.has(getFilterVal(f, key).trim()); - }) - ); - }, [findings, columnFilters]); + if (active.length > 0) { + result = result.filter((f) => + active.every(([key, vals]) => { + if (!vals || vals.size === 0) return false; + const def = COLUMN_DEFS[key]; + if (def?.multiValue) { + return (f[key] || []).some((v) => vals.has(String(v).trim())); + } + return vals.has(getFilterVal(f, key).trim()); + }) + ); + } + + // Action coverage filter (chart segment click) + if (actionFilter) { + result = result.filter((f) => classifyFinding(f) === actionFilter); + } + + // EXC filter (navigated from home page Archer ticket) + if (excFilter) { + const upper = excFilter.toUpperCase(); + result = result.filter((f) => (f.note || '').toUpperCase().includes(upper)); + } + + return result; + }, [findings, columnFilters, actionFilter, excFilter]); // Visible columns in current order const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]); @@ -966,7 +1009,7 @@ export default function ReportingPage({ filterDate }) { ); }; - const activeFilterCount = Object.keys(columnFilters).length; + const activeFilterCount = Object.keys(columnFilters).length + (actionFilter ? 1 : 0) + (excFilter ? 1 : 0); const [exportMenuOpen, setExportMenuOpen] = useState(false); const exportBtnRef = useRef(null); @@ -1073,12 +1116,20 @@ export default function ReportingPage({ filterDate }) { {/* Divider */}
- {/* FP# Workflow state donut */} + {/* Action Coverage donut */}
- FP# Workflow Status + Action Coverage + {actionFilter && d.key === actionFilter)?.color, fontSize: '0.6rem' }}>● filtered}
- + { + setExcFilter(null); + setActionFilter(key); + }} + />
@@ -1117,7 +1168,46 @@ export default function ReportingPage({ filterDate }) { {/* Action buttons */}
- {activeFilterCount > 0 && ( + {/* EXC filter badge (from home page navigation) */} + {excFilter && ( + + )} + {/* Action coverage filter badge (from chart click) */} + {actionFilter && ( + + )} + {Object.keys(columnFilters).length > 0 && (