feat(reporting): Action Coverage chart + Archer Exception linking

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 13:06:54 -06:00
parent a9404ff82a
commit 071aef96a1
2 changed files with 177 additions and 79 deletions

View File

@@ -179,6 +179,7 @@ export default function App() {
const [currentPage, setCurrentPage] = useState('home'); const [currentPage, setCurrentPage] = useState('home');
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [calendarFilter, setCalendarFilter] = useState(null); const [calendarFilter, setCalendarFilter] = useState(null);
const [reportingExcFilter, setReportingExcFilter] = useState(null);
const [showAddCVE, setShowAddCVE] = useState(false); const [showAddCVE, setShowAddCVE] = useState(false);
const [showUserManagement, setShowUserManagement] = useState(false); const [showUserManagement, setShowUserManagement] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false);
@@ -963,8 +964,8 @@ export default function App() {
onClose={() => setNavOpen(false)} onClose={() => setNavOpen(false)}
currentPage={currentPage} currentPage={currentPage}
onNavigate={(page) => { onNavigate={(page) => {
// Clear calendar filter when navigating directly via the nav drawer // Clear contextual filters when navigating directly via the nav drawer
if (page === 'reporting') setCalendarFilter(null); if (page === 'reporting') { setCalendarFilter(null); setReportingExcFilter(null); }
setCurrentPage(page); setCurrentPage(page);
}} }}
/> />
@@ -1041,7 +1042,7 @@ export default function App() {
</div> </div>
{/* Page content */} {/* Page content */}
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} />} {currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
{currentPage === 'knowledge-base' && <KnowledgeBasePage />} {currentPage === 'knowledge-base' && <KnowledgeBasePage />}
{currentPage === 'exports' && <ExportsPage />} {currentPage === 'exports' && <ExportsPage />}
@@ -2332,16 +2333,23 @@ export default function App() {
> >
{ticket.exc_number} {ticket.exc_number}
</a> </a>
{canWrite() && ( <div className="flex gap-1">
<div className="flex gap-1"> <button
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('reporting'); }}
title="View findings referencing this ticket"
className="text-gray-400 hover:text-sky-400 transition-colors"
>
<Filter className="w-3 h-3" />
</button>
{canWrite() && (<>
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors"> <button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
<Edit2 className="w-3 h-3" /> <Edit2 className="w-3 h-3" />
</button> </button>
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors"> <button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
</div> </>)}
)} </div>
</div> </div>
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div> <div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
<div className="text-xs text-gray-400">{ticket.vendor}</div> <div className="text-xs text-gray-400">{ticket.vendor}</div>

View File

@@ -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 // 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 = [ const ACTION_DEFS = [
{ key: 'expired', label: 'Expired', color: '#EF4444' }, { key: 'fp', label: 'FP Request', color: '#0EA5E9' },
{ key: 'rejected', label: 'Rejected', color: '#F87171' }, { key: 'archer', label: 'Archer Exception', color: '#F59E0B' },
{ key: 'reworked', label: 'Reworked', color: '#F59E0B' }, { key: 'pending', label: 'Pending', color: '#EF4444' },
{ 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' },
]; ];
function WorkflowDonut({ findings }) { function ActionCoverageDonut({ findings, activeSegment, onSegmentClick }) {
const SIZE = 180; const SIZE = 180;
const CX = SIZE / 2; const CX = SIZE / 2;
const CY = SIZE / 2; const CY = SIZE / 2;
@@ -285,16 +292,12 @@ function WorkflowDonut({ findings }) {
const INNER = 48; const INNER = 48;
const counts = useMemo(() => { const counts = useMemo(() => {
const map = Object.fromEntries(WF_STATE_DEFS.map((d) => [d.key, 0])); const map = { fp: 0, archer: 0, pending: 0 };
findings.forEach((f) => { findings.forEach((f) => { map[classifyFinding(f)]++; });
const state = (f.workflow?.state || '').toLowerCase();
if (state && state in map) map[state]++;
else map.none++;
});
return map; return map;
}, [findings]); }, [findings]);
const total = Object.values(counts).reduce((a, b) => a + b, 0); const total = findings.length;
if (total === 0) { if (total === 0) {
return ( return (
@@ -305,29 +308,35 @@ function WorkflowDonut({ findings }) {
} }
let cursor = 0; let cursor = 0;
const segments = WF_STATE_DEFS const segments = ACTION_DEFS.map((def) => {
.map((def) => { const count = counts[def.key];
const count = counts[def.key]; const start = cursor;
if (!count) return null; const end = count > 0 ? cursor + (count / total) * 360 : cursor;
const start = cursor; if (count > 0) cursor = end;
const end = cursor + (count / total) * 360; return { ...def, count, start, end };
cursor = end; });
return { ...def, count, start, end };
}) const hasActive = !!activeSegment;
.filter(Boolean);
return ( return (
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}> <svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" /> <circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
{segments.map((seg) => ( {segments.filter((s) => s.count > 0).map((seg) => {
<path const isActive = activeSegment === seg.key;
key={seg.key} return (
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)} <path
fill={seg.color} key={seg.key}
opacity={0.88} d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
/> fill={seg.color}
))} opacity={hasActive ? (isActive ? 1 : 0.25) : 0.88}
stroke={isActive ? 'rgba(255,255,255,0.6)' : 'none'}
strokeWidth={isActive ? 2 : 0}
style={{ cursor: 'pointer', transition: 'opacity 0.2s' }}
onClick={() => onSegmentClick(isActive ? null : seg.key)}
/>
);
})}
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}> <text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
{total.toLocaleString()} {total.toLocaleString()}
</text> </text>
@@ -336,24 +345,40 @@ function WorkflowDonut({ findings }) {
</text> </text>
</svg> </svg>
{/* Legend */} {/* Legend — always shows all 3 categories */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{segments.map((seg) => ( {segments.map((seg) => {
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> const isActive = activeSegment === seg.key;
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} /> const dimmed = hasActive && !isActive;
<div> return (
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}> <div
{seg.label} key={seg.key}
</span> onClick={() => onSegmentClick(isActive ? null : seg.key)}
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}> style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: dimmed ? 0.35 : 1, transition: 'opacity 0.2s' }}
{seg.count} >
</span> <div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0, outline: isActive ? `2px solid ${seg.color}` : 'none', outlineOffset: '1px' }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}> <div>
({((seg.count / total) * 100).toFixed(0)}%) <span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
</span> {seg.label}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
{seg.count}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
({total > 0 ? ((seg.count / total) * 100).toFixed(0) : 0}%)
</span>
</div>
</div> </div>
</div> );
))} })}
{hasActive && (
<button
onClick={() => onSegmentClick(null)}
style={{ marginTop: '0.25rem', background: 'none', border: 'none', fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', cursor: 'pointer', textAlign: 'left', padding: 0, textDecoration: 'underline' }}
>
clear filter
</button>
)}
</div> </div>
</div> </div>
); );
@@ -835,7 +860,7 @@ function TableCell({ colKey, finding }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main ReportingPage // Main ReportingPage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function ReportingPage({ filterDate }) { export default function ReportingPage({ filterDate, filterEXC }) {
const [findings, setFindings] = useState([]); const [findings, setFindings] = useState([]);
const [total, setTotal] = useState(null); const [total, setTotal] = useState(null);
const [syncedAt, setSyncedAt] = useState(null); const [syncedAt, setSyncedAt] = useState(null);
@@ -852,6 +877,8 @@ export default function ReportingPage({ filterDate }) {
); );
const [openFilter, setOpenFilter] = useState(null); const [openFilter, setOpenFilter] = useState(null);
const filterBtnRefs = useRef({}); const filterBtnRefs = useRef({});
const [actionFilter, setActionFilter] = useState(null);
const [excFilter, setExcFilter] = useState(filterEXC || null);
const updateColumns = useCallback((newOrder) => { const updateColumns = useCallback((newOrder) => {
setColumnOrder(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(() => { const filtered = useMemo(() => {
let result = findings;
// Column filters
const active = Object.entries(columnFilters); const active = Object.entries(columnFilters);
if (active.length === 0) return findings; if (active.length > 0) {
return findings.filter((f) => result = result.filter((f) =>
active.every(([key, vals]) => { active.every(([key, vals]) => {
if (!vals || vals.size === 0) return false; if (!vals || vals.size === 0) return false;
const def = COLUMN_DEFS[key]; const def = COLUMN_DEFS[key];
if (def?.multiValue) { 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 (f[key] || []).some((v) => vals.has(String(v).trim())); }
} return vals.has(getFilterVal(f, key).trim());
return vals.has(getFilterVal(f, key).trim()); })
}) );
); }
}, [findings, columnFilters]);
// 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 // Visible columns in current order
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]); 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 [exportMenuOpen, setExportMenuOpen] = useState(false);
const exportBtnRef = useRef(null); const exportBtnRef = useRef(null);
@@ -1073,12 +1116,20 @@ export default function ReportingPage({ filterDate }) {
{/* Divider */} {/* Divider */}
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} /> <div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
{/* FP# Workflow state donut */} {/* Action Coverage donut */}
<div style={{ flex: '0 0 auto' }}> <div style={{ flex: '0 0 auto' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}> <div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
FP# Workflow Status Action Coverage
{actionFilter && <span style={{ marginLeft: '0.5rem', color: ACTION_DEFS.find(d => d.key === actionFilter)?.color, fontSize: '0.6rem' }}> filtered</span>}
</div> </div>
<WorkflowDonut findings={findings} /> <ActionCoverageDonut
findings={findings}
activeSegment={actionFilter}
onSegmentClick={(key) => {
setExcFilter(null);
setActionFilter(key);
}}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -1117,7 +1168,46 @@ export default function ReportingPage({ filterDate }) {
{/* Action buttons */} {/* Action buttons */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{activeFilterCount > 0 && ( {/* EXC filter badge (from home page navigation) */}
{excFilter && (
<button
onClick={() => setExcFilter(null)}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: 'rgba(245,158,11,0.08)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: '0.375rem',
color: '#F59E0B', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
letterSpacing: '0.05em'
}}
>
<Filter style={{ width: '11px', height: '11px' }} />
{excFilter} ×
</button>
)}
{/* Action coverage filter badge (from chart click) */}
{actionFilter && (
<button
onClick={() => setActionFilter(null)}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: actionFilter === 'fp' ? 'rgba(14,165,233,0.08)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.08)' : 'rgba(239,68,68,0.08)',
border: `1px solid ${actionFilter === 'fp' ? 'rgba(14,165,233,0.3)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.3)' : 'rgba(239,68,68,0.3)'}`,
borderRadius: '0.375rem',
color: actionFilter === 'fp' ? '#0EA5E9' : actionFilter === 'archer' ? '#F59E0B' : '#EF4444',
cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
letterSpacing: '0.05em'
}}
>
<Filter style={{ width: '11px', height: '11px' }} />
{ACTION_DEFS.find(d => d.key === actionFilter)?.label} ×
</button>
)}
{Object.keys(columnFilters).length > 0 && (
<button <button
onClick={() => setColumnFilters({})} onClick={() => setColumnFilters({})}
style={{ style={{