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:
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user