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 [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() {
</div>
{/* Page content */}
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} />}
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
{currentPage === 'exports' && <ExportsPage />}
@@ -2332,16 +2333,23 @@ export default function App() {
>
{ticket.exc_number}
</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">
<Edit2 className="w-3 h-3" />
</button>
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</>)}
</div>
</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>

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
// ---------------------------------------------------------------------------
@@ -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 (
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
<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" />
{segments.map((seg) => (
<path
key={seg.key}
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
fill={seg.color}
opacity={0.88}
/>
))}
{segments.filter((s) => s.count > 0).map((seg) => {
const isActive = activeSegment === seg.key;
return (
<path
key={seg.key}
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' }}>
{total.toLocaleString()}
</text>
@@ -336,24 +345,40 @@ function WorkflowDonut({ findings }) {
</text>
</svg>
{/* Legend */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{segments.map((seg) => (
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
<div>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{seg.label}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
{seg.count}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
({((seg.count / total) * 100).toFixed(0)}%)
</span>
{/* Legend — always shows all 3 categories */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{segments.map((seg) => {
const isActive = activeSegment === seg.key;
const dimmed = hasActive && !isActive;
return (
<div
key={seg.key}
onClick={() => onSegmentClick(isActive ? null : seg.key)}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: dimmed ? 0.35 : 1, transition: 'opacity 0.2s' }}
>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0, outline: isActive ? `2px solid ${seg.color}` : 'none', outlineOffset: '1px' }} />
<div>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{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>
))}
);
})}
{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>
);
@@ -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 */}
<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={{ 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>
<WorkflowDonut findings={findings} />
<ActionCoverageDonut
findings={findings}
activeSegment={actionFilter}
onSegmentClick={(key) => {
setExcFilter(null);
setActionFilter(key);
}}
/>
</div>
</div>
</div>
@@ -1117,7 +1168,46 @@ export default function ReportingPage({ filterDate }) {
{/* Action buttons */}
<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
onClick={() => setColumnFilters({})}
style={{