Add Workflow column to Reporting page with FP# priority matching
- Backend: extractFinding now flattens all workflowDistribution buckets and prioritises FP# (False Positive) tickets over SYS# workflows. Falls back to workflowGeneratedNames for FP# IDs not yet in distribution. - Frontend: Add Workflow column (sortable, filterable) with state-coloured badge (green=Approved, blue=Requested, amber=Reworked/Actionable, red=Rejected, grey=Expired/unknown). - Bump localStorage key to v2 so the new column appears on all clients without needing a manual cache clear. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,39 @@ function extractFinding(f) {
|
|||||||
// CVE list: vulnerabilities.vulnInfoList[].cve
|
// CVE list: vulnerabilities.vulnInfoList[].cve
|
||||||
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
|
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
|
||||||
|
|
||||||
|
// Workflow: flatten all distribution buckets, prioritise FP# over SYS#
|
||||||
|
const wfDist = f.workflowDistribution || {};
|
||||||
|
const allWfEntries = [
|
||||||
|
...(wfDist.actionableWorkflows || []),
|
||||||
|
...(wfDist.requestedWorkflows || []),
|
||||||
|
...(wfDist.approvedWorkflows || []),
|
||||||
|
...(wfDist.reworkedWorkflows || []),
|
||||||
|
...(wfDist.rejectedWorkflows || []),
|
||||||
|
...(wfDist.expiredWorkflows || []),
|
||||||
|
...(wfDist.latestSystemWorkflows || []),
|
||||||
|
];
|
||||||
|
// FP# (False Positive tickets) take priority over SYS# (system workflows)
|
||||||
|
const fpEntry = allWfEntries.find(w => (w.generatedId || '').startsWith('FP#'));
|
||||||
|
const sysEntry = allWfEntries.find(w => (w.generatedId || '').startsWith('SYS#'));
|
||||||
|
const wfEntry = fpEntry || sysEntry || allWfEntries[0] || null;
|
||||||
|
|
||||||
|
// If the distribution didn't surface an FP#, also check workflowGeneratedNames directly.
|
||||||
|
// (Some FP# tickets only appear in the names list without full state info.)
|
||||||
|
const generatedNames = f.workflowGeneratedNames || [];
|
||||||
|
const fpFromNames = !fpEntry
|
||||||
|
? generatedNames.find(n => n.startsWith('FP#')) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const workflow = wfEntry ? {
|
||||||
|
id: wfEntry.generatedId || '',
|
||||||
|
state: wfEntry.state || '',
|
||||||
|
type: wfEntry.type || wfEntry.acronym || '',
|
||||||
|
} : fpFromNames ? {
|
||||||
|
id: fpFromNames,
|
||||||
|
state: '',
|
||||||
|
type: 'FP',
|
||||||
|
} : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(f.id),
|
id: String(f.id),
|
||||||
title: f.title || '',
|
title: f.title || '',
|
||||||
@@ -143,7 +176,8 @@ function extractFinding(f) {
|
|||||||
dueDate,
|
dueDate,
|
||||||
lastFoundOn: f.lastFoundOn || '',
|
lastFoundOn: f.lastFoundOn || '',
|
||||||
buOwnership,
|
buOwnership,
|
||||||
cves
|
cves,
|
||||||
|
workflow
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom';
|
|||||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter } from 'lucide-react';
|
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter } from 'lucide-react';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const STORAGE_KEY = 'steam_findings_columns_v1';
|
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Column definitions — source of truth for labels, sort behaviour, rendering
|
// Column definitions — source of truth for labels, sort behaviour, rendering
|
||||||
@@ -17,8 +17,9 @@ const COLUMN_DEFS = {
|
|||||||
ipAddress: { label: 'IP Address', sortable: true, filterable: true },
|
ipAddress: { label: 'IP Address', sortable: true, filterable: true },
|
||||||
dns: { label: 'DNS', sortable: true, filterable: true },
|
dns: { label: 'DNS', sortable: true, filterable: true },
|
||||||
dueDate: { label: 'Due Date', sortable: true, filterable: true },
|
dueDate: { label: 'Due Date', sortable: true, filterable: true },
|
||||||
slaStatus: { label: 'SLA', sortable: true, filterable: true },
|
slaStatus: { label: 'SLA', sortable: true, filterable: true },
|
||||||
buOwnership: { label: 'BU', sortable: true, filterable: true },
|
buOwnership: { label: 'BU', sortable: true, filterable: true },
|
||||||
|
workflow: { label: 'Workflow', sortable: true, filterable: true },
|
||||||
lastFoundOn: { label: 'Last Found', sortable: true, filterable: true },
|
lastFoundOn: { label: 'Last Found', sortable: true, filterable: true },
|
||||||
note: { label: 'Notes', sortable: false, filterable: false },
|
note: { label: 'Notes', sortable: false, filterable: false },
|
||||||
};
|
};
|
||||||
@@ -34,6 +35,7 @@ const DEFAULT_COLUMN_ORDER = [
|
|||||||
{ key: 'dueDate', visible: true },
|
{ key: 'dueDate', visible: true },
|
||||||
{ key: 'slaStatus', visible: true },
|
{ key: 'slaStatus', visible: true },
|
||||||
{ key: 'buOwnership', visible: true },
|
{ key: 'buOwnership', visible: true },
|
||||||
|
{ key: 'workflow', visible: true },
|
||||||
{ key: 'lastFoundOn', visible: true },
|
{ key: 'lastFoundOn', visible: true },
|
||||||
{ key: 'note', visible: true },
|
{ key: 'note', visible: true },
|
||||||
];
|
];
|
||||||
@@ -75,6 +77,7 @@ function getVal(finding, key) {
|
|||||||
case 'slaStatus': return finding.slaStatus ?? '';
|
case 'slaStatus': return finding.slaStatus ?? '';
|
||||||
case 'cves': return (finding.cves || []).length; // sort by CVE count
|
case 'cves': return (finding.cves || []).length; // sort by CVE count
|
||||||
case 'buOwnership': return finding.buOwnership ?? '';
|
case 'buOwnership': return finding.buOwnership ?? '';
|
||||||
|
case 'workflow': return finding.workflow?.id ?? '';
|
||||||
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
||||||
case 'note': return finding.note ?? '';
|
case 'note': return finding.note ?? '';
|
||||||
default: return '';
|
default: return '';
|
||||||
@@ -87,6 +90,7 @@ function getVal(finding, key) {
|
|||||||
function getFilterVal(finding, key) {
|
function getFilterVal(finding, key) {
|
||||||
if (key === 'severity') return finding.vrrGroup || '';
|
if (key === 'severity') return finding.vrrGroup || '';
|
||||||
if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic
|
if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic
|
||||||
|
if (key === 'workflow') return finding.workflow?.id || '';
|
||||||
return String(getVal(finding, key) ?? '');
|
return String(getVal(finding, key) ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +125,18 @@ function dueDateColor(dueDate) {
|
|||||||
return '#94A3B8';
|
return '#94A3B8';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowStyle(state) {
|
||||||
|
switch ((state || '').toLowerCase()) {
|
||||||
|
case 'approved': return { bg: 'rgba(16,185,129,0.12)', border: 'rgba(16,185,129,0.35)', text: '#10B981' };
|
||||||
|
case 'requested': return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.35)', text: '#0EA5E9' };
|
||||||
|
case 'actionable': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.35)', text: '#F59E0B' };
|
||||||
|
case 'reworked': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.35)', text: '#F59E0B' };
|
||||||
|
case 'rejected': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.35)', text: '#EF4444' };
|
||||||
|
case 'expired': return { bg: 'rgba(100,116,139,0.12)', border: 'rgba(100,116,139,0.3)', text: '#64748B' };
|
||||||
|
default: return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function SortIcon({ colKey, sort }) {
|
function SortIcon({ colKey, sort }) {
|
||||||
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
||||||
return sort.dir === 'asc'
|
return sort.dir === 'asc'
|
||||||
@@ -553,6 +569,30 @@ function TableCell({ colKey, finding }) {
|
|||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'workflow': {
|
||||||
|
const wf = finding.workflow;
|
||||||
|
if (!wf || !wf.id) return <td style={{ padding: '0.45rem 0.75rem', color: '#334155' }}>—</td>;
|
||||||
|
const ws = workflowStyle(wf.state);
|
||||||
|
return (
|
||||||
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||||
|
<span
|
||||||
|
title={`${wf.id} · ${wf.state || 'Unknown'} · ${wf.type || ''}`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||||
|
padding: '0.15rem 0.45rem', borderRadius: '0.25rem',
|
||||||
|
background: ws.bg, border: `1px solid ${ws.border}`,
|
||||||
|
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||||
|
color: ws.text, cursor: 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wf.id}
|
||||||
|
<span style={{ fontSize: '0.58rem', opacity: 0.8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
|
{wf.state}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
case 'lastFoundOn':
|
case 'lastFoundOn':
|
||||||
return (
|
return (
|
||||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user