3996 lines
209 KiB
JavaScript
3996 lines
209 KiB
JavaScript
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||
import ReactDOM from 'react-dom';
|
||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3 } from 'lucide-react';
|
||
import * as XLSX from 'xlsx';
|
||
import { useAuth } from '../../contexts/AuthContext';
|
||
import IvantiCountsChart from './IvantiCountsChart';
|
||
import CveTooltip from '../CveTooltip';
|
||
import RedirectModal from '../RedirectModal';
|
||
|
||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||
|
||
// Sentinel used in filter Sets to represent cells with no value (blank / —)
|
||
const EMPTY_SENTINEL = '__EMPTY__';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Column definitions — source of truth for labels, sort behaviour, rendering
|
||
// ---------------------------------------------------------------------------
|
||
const COLUMN_DEFS = {
|
||
findingId: { label: 'Finding ID', sortable: true, filterable: false },
|
||
severity: { label: 'Severity', sortable: true, filterable: true },
|
||
title: { label: 'Title', sortable: true, filterable: true },
|
||
cves: { label: 'CVEs', sortable: true, filterable: true, multiValue: true },
|
||
hostName: { label: 'Host', sortable: true, filterable: true },
|
||
ipAddress: { label: 'IP Address', sortable: true, filterable: true },
|
||
dns: { label: 'DNS', sortable: true, filterable: true },
|
||
dueDate: { label: 'Due Date', sortable: true, filterable: true },
|
||
slaStatus: { label: 'SLA', 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 },
|
||
note: { label: 'Notes', sortable: false, filterable: false },
|
||
};
|
||
|
||
const DEFAULT_COLUMN_ORDER = [
|
||
{ key: 'findingId', visible: true },
|
||
{ key: 'severity', visible: true },
|
||
{ key: 'title', visible: true },
|
||
{ key: 'cves', visible: true },
|
||
{ key: 'hostName', visible: true },
|
||
{ key: 'ipAddress', visible: true },
|
||
{ key: 'dns', visible: true },
|
||
{ key: 'dueDate', visible: true },
|
||
{ key: 'slaStatus', visible: true },
|
||
{ key: 'buOwnership', visible: true },
|
||
{ key: 'workflow', visible: true },
|
||
{ key: 'lastFoundOn', visible: true },
|
||
{ key: 'note', visible: true },
|
||
];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Persist / load column config
|
||
// ---------------------------------------------------------------------------
|
||
function loadColumnOrder() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
||
if (saved && Array.isArray(saved)) {
|
||
const savedKeys = new Set(saved.map((c) => c.key));
|
||
const merged = saved.filter((c) => COLUMN_DEFS[c.key]);
|
||
DEFAULT_COLUMN_ORDER.forEach((d) => {
|
||
if (!savedKeys.has(d.key)) merged.push({ ...d });
|
||
});
|
||
return merged;
|
||
}
|
||
} catch { /* ignore */ }
|
||
return DEFAULT_COLUMN_ORDER.map((c) => ({ ...c }));
|
||
}
|
||
|
||
function saveColumnOrder(order) {
|
||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(order)); } catch { /* ignore */ }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sort accessor by column key
|
||
// ---------------------------------------------------------------------------
|
||
function getVal(finding, key) {
|
||
switch (key) {
|
||
case 'findingId': return finding.id ?? '';
|
||
case 'severity': return finding.severity ?? 0;
|
||
case 'title': return finding.title ?? '';
|
||
case 'hostName': return finding.hostName ?? '';
|
||
case 'ipAddress': return finding.ipAddress ?? '';
|
||
case 'dns': return finding.dns ?? '';
|
||
case 'dueDate': return finding.dueDate ?? '';
|
||
case 'slaStatus': return finding.slaStatus ?? '';
|
||
case 'cves': return (finding.cves || []).length; // sort by CVE count
|
||
case 'buOwnership': return finding.buOwnership ?? '';
|
||
case 'workflow': return finding.workflow?.id ?? '';
|
||
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
||
case 'note': return finding.note ?? '';
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filter accessor — severity → vrrGroup label; cves handled as multi-value
|
||
// ---------------------------------------------------------------------------
|
||
function getFilterVal(finding, key) {
|
||
if (key === 'severity') return finding.vrrGroup || '';
|
||
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) ?? '');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Export value accessor — plain text representation for CSV/XLSX
|
||
// ---------------------------------------------------------------------------
|
||
function getExportVal(finding, key) {
|
||
switch (key) {
|
||
case 'findingId': return finding.id ?? '';
|
||
case 'severity': return finding.vrrGroup ? `${finding.severity?.toFixed(2)} ${finding.vrrGroup}` : String(finding.severity ?? '');
|
||
case 'title': return finding.title ?? '';
|
||
case 'cves': return (finding.cves || []).join(', ');
|
||
case 'hostName': return finding.hostName ?? '';
|
||
case 'ipAddress': return finding.ipAddress ?? '';
|
||
case 'dns': return finding.dns ?? '';
|
||
case 'dueDate': return finding.dueDate ?? '';
|
||
case 'slaStatus': return finding.slaStatus ?? '';
|
||
case 'buOwnership': return finding.buOwnership ?? '';
|
||
case 'workflow': return finding.workflow ? `${finding.workflow.id} (${finding.workflow.state})` : '';
|
||
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
||
case 'note': return finding.note ?? '';
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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
|
||
// ---------------------------------------------------------------------------
|
||
function severityColor(vrrGroup) {
|
||
switch ((vrrGroup || '').toUpperCase()) {
|
||
case 'CRITICAL': return { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#EF4444' };
|
||
case 'HIGH': return { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#F59E0B' };
|
||
case 'MEDIUM': return { bg: 'rgba(234,179,8,0.15)', border: '#EAB308', text: '#EAB308' };
|
||
default: return { bg: 'rgba(100,116,139,0.15)', border: '#64748B', text: '#94A3B8' };
|
||
}
|
||
}
|
||
|
||
function slaColor(slaStatus) {
|
||
switch ((slaStatus || '').toUpperCase()) {
|
||
case 'OVERDUE': return '#EF4444';
|
||
case 'AT_RISK': return '#F59E0B';
|
||
case 'WITHIN_SLA': return '#10B981';
|
||
default: return '#64748B';
|
||
}
|
||
}
|
||
|
||
function dueDateColor(dueDate) {
|
||
if (!dueDate) return '#64748B';
|
||
const today = new Date();
|
||
const due = new Date(dueDate);
|
||
const diffDays = Math.ceil((due - today) / (1000 * 60 * 60 * 24));
|
||
if (diffDays < 0) return '#EF4444';
|
||
if (diffDays <= 30) return '#F59E0B';
|
||
return '#94A3B8';
|
||
}
|
||
|
||
function workflowStyle(state) {
|
||
// Colors reflect action urgency — all findings here are Open, so Approved won't appear.
|
||
switch ((state || '').toLowerCase()) {
|
||
case 'expired': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; // overdue — renew FP
|
||
case 'rejected': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; // denied — must remediate
|
||
case 'reworked': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; // challenged — resubmit FP
|
||
case 'actionable': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; // needs action
|
||
case 'requested': return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.35)', text: '#0EA5E9' }; // in flight — awaiting approval
|
||
default: return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' }; // unknown state
|
||
}
|
||
}
|
||
|
||
function lifecycleStatusBadge(status) {
|
||
switch ((status || '').toLowerCase()) {
|
||
case 'submitted':
|
||
case 'resubmitted':
|
||
return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.4)', text: '#0EA5E9' };
|
||
case 'approved':
|
||
return { bg: 'rgba(16,185,129,0.12)', border: 'rgba(16,185,129,0.4)', text: '#10B981' };
|
||
case 'rejected':
|
||
return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' };
|
||
case 'rework':
|
||
return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' };
|
||
default:
|
||
return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' };
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SVG Donut Chart — Open vs Closed findings
|
||
// ---------------------------------------------------------------------------
|
||
function polarToCartesian(cx, cy, r, angleDeg) {
|
||
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||
}
|
||
|
||
function donutArcPath(cx, cy, outerR, innerR, startDeg, endDeg) {
|
||
// Full circle must be split into two arcs (SVG can't render a 360° arc)
|
||
if (Math.abs(endDeg - startDeg) >= 359.9) {
|
||
const mid = startDeg + 180;
|
||
return donutArcPath(cx, cy, outerR, innerR, startDeg, mid) + ' ' +
|
||
donutArcPath(cx, cy, outerR, innerR, mid, endDeg);
|
||
}
|
||
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||
const s = polarToCartesian(cx, cy, outerR, startDeg);
|
||
const e = polarToCartesian(cx, cy, outerR, endDeg);
|
||
const si = polarToCartesian(cx, cy, innerR, endDeg);
|
||
const ei = polarToCartesian(cx, cy, innerR, startDeg);
|
||
return [
|
||
`M ${s.x.toFixed(2)} ${s.y.toFixed(2)}`,
|
||
`A ${outerR} ${outerR} 0 ${largeArc} 1 ${e.x.toFixed(2)} ${e.y.toFixed(2)}`,
|
||
`L ${si.x.toFixed(2)} ${si.y.toFixed(2)}`,
|
||
`A ${innerR} ${innerR} 0 ${largeArc} 0 ${ei.x.toFixed(2)} ${ei.y.toFixed(2)}`,
|
||
'Z',
|
||
].join(' ');
|
||
}
|
||
|
||
function StatusDonut({ open, closed, loading }) {
|
||
const SIZE = 180;
|
||
const CX = SIZE / 2;
|
||
const CY = SIZE / 2;
|
||
const OUTER = 72;
|
||
const INNER = 48;
|
||
|
||
if (loading) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const total = open + closed;
|
||
if (total === 0) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data — click Sync to load</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const openDeg = (open / total) * 360;
|
||
const segments = [
|
||
{ label: 'Open', count: open, color: '#0EA5E9', start: 0, end: openDeg },
|
||
{ label: 'Closed', count: closed, color: '#475569', start: openDeg, end: 360 },
|
||
].filter((s) => s.count > 0);
|
||
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
||
{/* Gap ring behind slices */}
|
||
<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.label}
|
||
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||
fill={seg.color}
|
||
opacity={0.88}
|
||
/>
|
||
))}
|
||
{/* Center total */}
|
||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||
{total.toLocaleString()}
|
||
</text>
|
||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||
TOTAL
|
||
</text>
|
||
</svg>
|
||
|
||
{/* Legend */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||
{segments.map((seg) => (
|
||
<div key={seg.label} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||
<div>
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||
{seg.label}
|
||
</div>
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', lineHeight: 1.2 }}>
|
||
{seg.count.toLocaleString()}
|
||
<span style={{ fontSize: '0.68rem', fontWeight: '400', color: '#64748B', marginLeft: '0.4rem' }}>
|
||
({((seg.count / total) * 100).toFixed(1)}%)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SVG Donut Chart — Action Coverage (FP Request | Archer Exception | Pending)
|
||
// ---------------------------------------------------------------------------
|
||
const ACTION_DEFS = [
|
||
{ key: 'fp', label: 'FP Request', color: '#0EA5E9' },
|
||
{ key: 'archer', label: 'Archer Exception', color: '#F59E0B' },
|
||
{ key: 'pending', label: 'Pending', color: '#EF4444' },
|
||
];
|
||
|
||
function ActionCoverageDonut({ findings, activeSegment, onSegmentClick }) {
|
||
const SIZE = 180;
|
||
const CX = SIZE / 2;
|
||
const CY = SIZE / 2;
|
||
const OUTER = 72;
|
||
const INNER = 48;
|
||
|
||
const counts = useMemo(() => {
|
||
const map = { fp: 0, archer: 0, pending: 0 };
|
||
findings.forEach((f) => { map[classifyFinding(f)]++; });
|
||
return map;
|
||
}, [findings]);
|
||
|
||
const total = findings.length;
|
||
|
||
if (total === 0) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data — click Sync to load</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
let cursor = 0;
|
||
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.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>
|
||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||
TOTAL
|
||
</text>
|
||
</svg>
|
||
|
||
{/* 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>
|
||
);
|
||
})}
|
||
{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>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SVG Donut Chart — FP Workflow Status distribution
|
||
// ---------------------------------------------------------------------------
|
||
const FP_WORKFLOW_DEFS = [
|
||
{ key: 'Actionable', label: 'Actionable', color: '#F59E0B' },
|
||
{ key: 'Requested', label: 'Requested', color: '#0EA5E9' },
|
||
{ key: 'Reworked', label: 'Reworked', color: '#A855F7' },
|
||
{ key: 'Approved', label: 'Approved', color: '#22C55E' },
|
||
{ key: 'Rejected', label: 'Rejected', color: '#EF4444' },
|
||
{ key: 'Expired', label: 'Expired', color: '#64748B' },
|
||
{ key: 'Unknown', label: 'Unknown', color: '#334155' },
|
||
];
|
||
|
||
function FPWorkflowDonut({ counts, total, centerLabel = 'FP TOTAL' }) {
|
||
const SIZE = 180;
|
||
const CX = SIZE / 2;
|
||
const CY = SIZE / 2;
|
||
const OUTER = 72;
|
||
const INNER = 48;
|
||
|
||
if (total === 0) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No FP workflows — click Sync to load</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
let cursor = 0;
|
||
const segments = FP_WORKFLOW_DEFS.map((def) => {
|
||
const count = counts[def.key] || 0;
|
||
const start = cursor;
|
||
const end = count > 0 ? cursor + (count / total) * 360 : cursor;
|
||
if (count > 0) cursor = end;
|
||
return { ...def, count, start, end };
|
||
}).filter(s => s.count > 0);
|
||
|
||
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}
|
||
/>
|
||
))}
|
||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||
{total.toLocaleString()}
|
||
</text>
|
||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||
{centerLabel}
|
||
</text>
|
||
</svg>
|
||
|
||
{/* Legend */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||
{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.8rem', 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>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SortIcon({ colKey, sort }) {
|
||
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
||
return sort.dir === 'asc'
|
||
? <ChevronUp style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />
|
||
: <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// OverrideCell — inline editable hostname/dns with amber dot when overridden
|
||
// ---------------------------------------------------------------------------
|
||
function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite }) {
|
||
const effective = initialOverride ?? originalValue ?? '';
|
||
const [value, setValue] = useState(effective);
|
||
const [isOverridden, setOverridden] = useState(!!initialOverride);
|
||
const [editing, setEditing] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const lastSaved = useRef(effective);
|
||
const inputRef = useRef(null);
|
||
|
||
// Sync when the finding updates (e.g. after a full sync)
|
||
useEffect(() => {
|
||
const eff = initialOverride ?? originalValue ?? '';
|
||
setValue(eff);
|
||
setOverridden(!!initialOverride);
|
||
lastSaved.current = eff;
|
||
}, [initialOverride, originalValue]);
|
||
|
||
useEffect(() => {
|
||
if (editing && inputRef.current) inputRef.current.focus();
|
||
}, [editing]);
|
||
|
||
const persist = useCallback(async (newVal) => {
|
||
const trimmed = newVal.trim();
|
||
if (trimmed === lastSaved.current) return;
|
||
setSaving(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/findings/${findingId}/override`, {
|
||
method: 'PUT',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ field, value: trimmed }),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const cleared = data.value === null;
|
||
const displayed = cleared ? (originalValue ?? '') : trimmed;
|
||
setValue(displayed);
|
||
setOverridden(!cleared);
|
||
lastSaved.current = displayed;
|
||
} else {
|
||
setValue(lastSaved.current); // revert on error
|
||
}
|
||
} catch {
|
||
setValue(lastSaved.current);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}, [findingId, field, originalValue]);
|
||
|
||
const handleBlur = () => { setEditing(false); persist(value); };
|
||
const handleKeyDown = (e) => {
|
||
if (e.key === 'Enter') { e.target.blur(); }
|
||
if (e.key === 'Escape') { setValue(lastSaved.current); setEditing(false); }
|
||
};
|
||
const handleRevert = (e) => { e.stopPropagation(); setValue(''); persist(''); };
|
||
|
||
if (editing) {
|
||
return (
|
||
<td style={{ padding: '0.3rem 0.5rem' }}>
|
||
<input
|
||
ref={inputRef}
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
onBlur={handleBlur}
|
||
onKeyDown={handleKeyDown}
|
||
style={{
|
||
width: '100%', minWidth: '120px',
|
||
background: 'rgba(14,165,233,0.08)',
|
||
border: '1px solid rgba(14,165,233,0.4)',
|
||
borderRadius: '0.25rem',
|
||
padding: '0.2rem 0.4rem',
|
||
color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.72rem',
|
||
outline: 'none',
|
||
}}
|
||
/>
|
||
</td>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||
<span
|
||
onClick={canWrite ? () => setEditing(true) : undefined}
|
||
title={isOverridden ? `Ivanti value: ${originalValue || '—'}\nClick to edit` : canWrite ? 'Click to edit' : undefined}
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||
color: isOverridden ? '#E2E8F0' : '#94A3B8',
|
||
fontFamily: 'monospace', fontSize: '0.72rem',
|
||
cursor: canWrite ? 'text' : 'default',
|
||
}}
|
||
>
|
||
{isOverridden && (
|
||
<span title="Local override active" style={{ width: '5px', height: '5px', borderRadius: '50%', background: '#F59E0B', flexShrink: 0, marginRight: '1px' }} />
|
||
)}
|
||
{value || '—'}
|
||
{saving && <Loader style={{ width: '10px', height: '10px', color: '#475569', animation: 'spin 1s linear infinite', flexShrink: 0 }} />}
|
||
{isOverridden && canWrite && !saving && (
|
||
<button
|
||
onClick={handleRevert}
|
||
title="Revert to Ivanti value"
|
||
style={{ background: 'none', border: 'none', padding: '0 1px', cursor: 'pointer', color: '#475569', lineHeight: 1, flexShrink: 0, display: 'inline-flex', alignItems: 'center' }}
|
||
>
|
||
<RotateCcw style={{ width: '10px', height: '10px' }} />
|
||
</button>
|
||
)}
|
||
</span>
|
||
</td>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// NoteCell — inline editable, saves on blur
|
||
// ---------------------------------------------------------------------------
|
||
function NoteCell({ findingId, initialNote }) {
|
||
const [value, setValue] = useState(initialNote || '');
|
||
const [saving, setSaving] = useState(false);
|
||
const lastSaved = useRef(initialNote || '');
|
||
|
||
useEffect(() => {
|
||
setValue(initialNote || '');
|
||
lastSaved.current = initialNote || '';
|
||
}, [initialNote]);
|
||
|
||
const save = useCallback(async () => {
|
||
if (value === lastSaved.current) return;
|
||
setSaving(true);
|
||
try {
|
||
await fetch(`${API_BASE}/ivanti/findings/${encodeURIComponent(findingId)}/note`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({ note: value })
|
||
});
|
||
lastSaved.current = value;
|
||
} catch (e) {
|
||
console.error('Failed to save note:', e);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}, [findingId, value]);
|
||
|
||
return (
|
||
<div style={{ position: 'relative' }}>
|
||
<input
|
||
type="text"
|
||
value={value}
|
||
maxLength={255}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
onBlur={save}
|
||
placeholder="Add note…"
|
||
style={{
|
||
width: '100%', minWidth: '160px',
|
||
background: 'rgba(14,165,233,0.05)',
|
||
border: '1px solid rgba(14,165,233,0.2)',
|
||
borderRadius: '4px', padding: '4px 8px',
|
||
color: '#CBD5E1', fontSize: '0.75rem',
|
||
fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box'
|
||
}}
|
||
onFocus={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.6)'; e.target.style.background = 'rgba(14,165,233,0.1)'; }}
|
||
onBlurCapture={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.2)'; e.target.style.background = 'rgba(14,165,233,0.05)'; }}
|
||
/>
|
||
{saving && <Loader style={{ width: '10px', height: '10px', position: 'absolute', right: '6px', top: '6px', color: '#0EA5E9' }} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ColumnManager — popover with drag-to-reorder and show/hide toggles
|
||
// ---------------------------------------------------------------------------
|
||
function ColumnManager({ columnOrder, onChange }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [dragIdx, setDragIdx] = useState(null);
|
||
const [overIdx, setOverIdx] = useState(null);
|
||
const panelRef = useRef(null);
|
||
const btnRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const handler = (e) => {
|
||
if (!panelRef.current?.contains(e.target) && !btnRef.current?.contains(e.target)) setOpen(false);
|
||
};
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [open]);
|
||
|
||
const toggleVisible = (key) => {
|
||
onChange(columnOrder.map((c) => c.key === key ? { ...c, visible: !c.visible } : c));
|
||
};
|
||
|
||
const handleDragStart = (idx) => setDragIdx(idx);
|
||
const handleDragOver = (e, idx) => { e.preventDefault(); setOverIdx(idx); };
|
||
const handleDrop = (idx) => {
|
||
if (dragIdx === null || dragIdx === idx) { setDragIdx(null); setOverIdx(null); return; }
|
||
const updated = [...columnOrder];
|
||
const [moved] = updated.splice(dragIdx, 1);
|
||
updated.splice(idx, 0, moved);
|
||
onChange(updated);
|
||
setDragIdx(null);
|
||
setOverIdx(null);
|
||
};
|
||
|
||
const visibleCount = columnOrder.filter((c) => c.visible).length;
|
||
|
||
return (
|
||
<div style={{ position: 'relative' }}>
|
||
<button
|
||
ref={btnRef}
|
||
onClick={() => setOpen((p) => !p)}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.375rem 0.75rem',
|
||
background: open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.07)',
|
||
border: `1px solid rgba(14,165,233,${open ? '0.5' : '0.25'})`,
|
||
borderRadius: '0.375rem',
|
||
color: '#0EA5E9', cursor: 'pointer',
|
||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em'
|
||
}}
|
||
>
|
||
<Settings2 style={{ width: '13px', height: '13px' }} />
|
||
Columns
|
||
<span style={{ fontSize: '0.65rem', opacity: 0.7 }}>({visibleCount}/{columnOrder.length})</span>
|
||
</button>
|
||
|
||
{open && (
|
||
<div
|
||
ref={panelRef}
|
||
style={{
|
||
position: 'absolute', top: 'calc(100% + 8px)', right: 0,
|
||
width: '220px', zIndex: 100,
|
||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||
border: '1px solid rgba(14,165,233,0.25)',
|
||
borderRadius: '0.5rem',
|
||
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
|
||
padding: '0.5rem'
|
||
}}
|
||
>
|
||
<div style={{ fontSize: '0.65rem', color: '#475569', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', padding: '0.25rem 0.5rem 0.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)', marginBottom: '0.375rem' }}>
|
||
Drag to reorder · click to toggle
|
||
</div>
|
||
{columnOrder.map((col, idx) => {
|
||
const def = COLUMN_DEFS[col.key];
|
||
const isDragging = dragIdx === idx;
|
||
const isOver = overIdx === idx && dragIdx !== null && dragIdx !== idx;
|
||
return (
|
||
<div
|
||
key={col.key}
|
||
draggable
|
||
onDragStart={() => handleDragStart(idx)}
|
||
onDragOver={(e) => handleDragOver(e, idx)}
|
||
onDrop={() => handleDrop(idx)}
|
||
onDragEnd={() => { setDragIdx(null); setOverIdx(null); }}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||
padding: '0.4rem 0.5rem', borderRadius: '0.25rem', cursor: 'grab',
|
||
opacity: isDragging ? 0.4 : 1,
|
||
background: isOver ? 'rgba(14,165,233,0.12)' : 'transparent',
|
||
borderTop: isOver ? '2px solid #0EA5E9' : '2px solid transparent',
|
||
transition: 'background 0.1s'
|
||
}}
|
||
>
|
||
<GripVertical style={{ width: '14px', height: '14px', color: '#334155', flexShrink: 0 }} />
|
||
<span style={{ flex: 1, fontSize: '0.78rem', color: col.visible ? '#CBD5E1' : '#475569', fontFamily: 'monospace' }}>
|
||
{def?.label || col.key}
|
||
</span>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); toggleVisible(col.key); }}
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: col.visible ? '#0EA5E9' : '#334155', lineHeight: 1 }}
|
||
>
|
||
{col.visible ? <Eye style={{ width: '14px', height: '14px' }} /> : <EyeOff style={{ width: '14px', height: '14px' }} />}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// FilterDropdown — portal-based so it escapes overflow:auto clipping
|
||
// ---------------------------------------------------------------------------
|
||
function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChange, onClose }) {
|
||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||
const [search, setSearch] = useState('');
|
||
const panelRef = useRef(null);
|
||
const inputRef = useRef(null);
|
||
|
||
// Compute fixed position from anchor button's viewport rect
|
||
useEffect(() => {
|
||
if (!anchorEl) return;
|
||
const r = anchorEl.getBoundingClientRect();
|
||
setPos({ top: r.bottom + 4, left: r.left });
|
||
setTimeout(() => inputRef.current?.focus(), 0);
|
||
}, [anchorEl]);
|
||
|
||
// Close on outside click
|
||
useEffect(() => {
|
||
const handler = (e) => {
|
||
if (panelRef.current && !panelRef.current.contains(e.target) &&
|
||
!(anchorEl && anchorEl.contains(e.target))) {
|
||
onClose();
|
||
}
|
||
};
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [anchorEl, onClose]);
|
||
|
||
// Close on Escape
|
||
useEffect(() => {
|
||
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||
document.addEventListener('keydown', handler);
|
||
return () => document.removeEventListener('keydown', handler);
|
||
}, [onClose]);
|
||
|
||
// Unique values from the full (unfiltered) findings list.
|
||
// Multi-value columns (e.g. cves) expand their array so each item is a separate option.
|
||
// EMPTY_SENTINEL is prepended when any finding has a blank/null cell.
|
||
const allValues = useMemo(() => {
|
||
const def = COLUMN_DEFS[colKey];
|
||
const vals = new Set();
|
||
let hasEmpty = false;
|
||
findings.forEach((f) => {
|
||
if (def?.multiValue) {
|
||
const arr = f[colKey] || [];
|
||
if (arr.length === 0) { hasEmpty = true; return; }
|
||
arr.forEach((v) => { if (String(v).trim()) vals.add(String(v).trim()); });
|
||
} else {
|
||
const v = getFilterVal(f, colKey).trim();
|
||
if (v) vals.add(v); else hasEmpty = true;
|
||
}
|
||
});
|
||
const sorted = [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||
if (hasEmpty) sorted.unshift(EMPTY_SENTINEL);
|
||
return sorted;
|
||
}, [findings, colKey]);
|
||
|
||
const displayed = search.trim()
|
||
? allValues.filter((v) => v === EMPTY_SENTINEL || v.toLowerCase().includes(search.toLowerCase()))
|
||
: allValues;
|
||
|
||
const isChecked = (val) => !activeFilter || activeFilter.has(val);
|
||
const activeCount = activeFilter ? activeFilter.size : allValues.length;
|
||
|
||
const toggle = (val) => {
|
||
let next;
|
||
if (!activeFilter) {
|
||
next = new Set(allValues);
|
||
next.delete(val);
|
||
} else {
|
||
next = new Set(activeFilter);
|
||
if (next.has(val)) next.delete(val); else next.add(val);
|
||
}
|
||
// If all values selected again, remove the filter entirely
|
||
onFilterChange(next.size >= allValues.length ? null : next);
|
||
};
|
||
|
||
return ReactDOM.createPortal(
|
||
<div
|
||
ref={panelRef}
|
||
style={{
|
||
position: 'fixed', top: pos.top, left: pos.left,
|
||
width: '220px', zIndex: 9999,
|
||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||
border: '1px solid rgba(14,165,233,0.3)',
|
||
borderRadius: '0.5rem',
|
||
boxShadow: '0 8px 32px rgba(0,0,0,0.8)',
|
||
padding: '0.5rem',
|
||
}}
|
||
>
|
||
{/* Search */}
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="Search values…"
|
||
style={{
|
||
width: '100%', marginBottom: '0.375rem',
|
||
background: 'rgba(14,165,233,0.05)',
|
||
border: '1px solid rgba(14,165,233,0.2)',
|
||
borderRadius: '0.25rem', padding: '0.3rem 0.5rem',
|
||
color: '#CBD5E1', fontSize: '0.72rem',
|
||
fontFamily: 'monospace', outline: 'none', boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
|
||
{/* Select All / Clear */}
|
||
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '0.375rem', paddingBottom: '0.375rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||
<button
|
||
onClick={() => onFilterChange(null)}
|
||
style={{ flex: 1, padding: '0.2rem', background: 'rgba(14,165,233,0.08)', border: '1px solid rgba(14,165,233,0.2)', borderRadius: '0.25rem', color: '#0EA5E9', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||
>
|
||
Select All
|
||
</button>
|
||
<button
|
||
onClick={() => onFilterChange(new Set())}
|
||
style={{ flex: 1, padding: '0.2rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '0.25rem', color: '#EF4444', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||
>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
|
||
{/* Value checkboxes */}
|
||
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
||
{displayed.length === 0 ? (
|
||
<div style={{ fontSize: '0.68rem', color: '#475569', textAlign: 'center', padding: '0.5rem 0' }}>No values</div>
|
||
) : displayed.map((val) => (
|
||
<label
|
||
key={val}
|
||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0.375rem', borderRadius: '0.25rem', cursor: 'pointer', color: isChecked(val) ? '#CBD5E1' : '#475569', fontSize: '0.72rem', fontFamily: 'monospace' }}
|
||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.08)'}
|
||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={isChecked(val)}
|
||
onChange={() => toggle(val)}
|
||
style={{ accentColor: '#0EA5E9', width: '12px', height: '12px', flexShrink: 0, cursor: 'pointer' }}
|
||
/>
|
||
{val === EMPTY_SENTINEL
|
||
? <span style={{ fontStyle: 'italic', color: '#64748B', whiteSpace: 'nowrap' }}>— empty —</span>
|
||
: <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
|
||
}
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
{/* Status footer */}
|
||
<div style={{ marginTop: '0.375rem', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: '0.65rem', color: '#475569', textAlign: 'center', fontFamily: 'monospace' }}>
|
||
{activeCount} / {allValues.length} selected
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Render a single table cell by column key
|
||
// ---------------------------------------------------------------------------
|
||
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission }) {
|
||
switch (colKey) {
|
||
case 'findingId':
|
||
return (
|
||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#475569', fontFamily: 'monospace', fontSize: '0.68rem' }}>
|
||
{finding.id || '—'}
|
||
</td>
|
||
);
|
||
case 'severity': {
|
||
const sc = severityColor(finding.vrrGroup);
|
||
return (
|
||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', padding: '0.2rem 0.45rem', borderRadius: '0.25rem', background: sc.bg, border: `1px solid ${sc.border}40`, fontFamily: 'monospace', fontWeight: '700', color: sc.text, fontSize: '0.72rem' }}>
|
||
{finding.severity?.toFixed(2)}
|
||
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{finding.vrrGroup}</span>
|
||
</span>
|
||
</td>
|
||
);
|
||
}
|
||
case 'title':
|
||
return (
|
||
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '280px' }}>
|
||
<span style={{ color: '#CBD5E1', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.title}>
|
||
{finding.title}
|
||
</span>
|
||
</td>
|
||
);
|
||
case 'cves': {
|
||
const cves = finding.cves || [];
|
||
if (cves.length === 0) return <td style={{ padding: '0.45rem 0.75rem', color: '#475569' }}>—</td>;
|
||
const shown = cves.slice(0, 2);
|
||
const rest = cves.length - shown.length;
|
||
return (
|
||
<td style={{ padding: '0.45rem 0.75rem', minWidth: '160px', maxWidth: '240px' }}>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
|
||
{shown.map((cve) => (
|
||
<span
|
||
key={cve}
|
||
onMouseEnter={onCveMouseEnter ? (e) => onCveMouseEnter(cve, e) : undefined}
|
||
onMouseLeave={onCveMouseLeave || undefined}
|
||
style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)', color: '#A78BFA', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600', whiteSpace: 'nowrap' }}
|
||
>
|
||
{cve}
|
||
</span>
|
||
))}
|
||
{rest > 0 && (
|
||
<span title={cves.slice(2).join('\n')} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(100,116,139,0.12)', border: '1px solid rgba(100,116,139,0.25)', color: '#64748B', fontFamily: 'monospace', fontSize: '0.65rem', cursor: 'help', whiteSpace: 'nowrap' }}>
|
||
+{rest} more
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
);
|
||
}
|
||
case 'hostName':
|
||
return (
|
||
<OverrideCell
|
||
findingId={finding.id}
|
||
field="hostName"
|
||
originalValue={finding.hostName}
|
||
initialOverride={finding.overrides?.hostName ?? null}
|
||
canWrite={canWrite}
|
||
/>
|
||
);
|
||
case 'ipAddress':
|
||
return (
|
||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||
{finding.ipAddress || '—'}
|
||
</td>
|
||
);
|
||
case 'dns':
|
||
return (
|
||
<OverrideCell
|
||
findingId={finding.id}
|
||
field="dns"
|
||
originalValue={finding.dns}
|
||
initialOverride={finding.overrides?.dns ?? null}
|
||
canWrite={canWrite}
|
||
/>
|
||
);
|
||
case 'dueDate': {
|
||
const color = dueDateColor(finding.dueDate);
|
||
return (
|
||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600', color }}>
|
||
{finding.dueDate || '—'}
|
||
</td>
|
||
);
|
||
}
|
||
case 'slaStatus':
|
||
return (
|
||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: slaColor(finding.slaStatus) }}>
|
||
{finding.slaStatus || '—'}
|
||
</td>
|
||
);
|
||
case 'buOwnership': {
|
||
const bu = finding.buOwnership || '';
|
||
const isSteam = bu.toUpperCase().includes('STEAM');
|
||
return (
|
||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||
{bu ? (
|
||
<span
|
||
title={bu}
|
||
style={{
|
||
display: 'inline-block', padding: '0.15rem 0.4rem',
|
||
borderRadius: '0.25rem',
|
||
background: isSteam ? 'rgba(14,165,233,0.1)' : 'rgba(245,158,11,0.1)',
|
||
border: `1px solid ${isSteam ? 'rgba(14,165,233,0.3)' : 'rgba(245,158,11,0.3)'}`,
|
||
color: isSteam ? '#0EA5E9' : '#F59E0B',
|
||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||
}}
|
||
>
|
||
{bu.replace('NTS-AEO-', '')}
|
||
</span>
|
||
) : (
|
||
<span style={{ color: '#475569' }}>—</span>
|
||
)}
|
||
</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':
|
||
return (
|
||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||
{finding.lastFoundOn || '—'}
|
||
</td>
|
||
);
|
||
case 'note':
|
||
return (
|
||
<td style={{ padding: '0.45rem 0.75rem' }}>
|
||
<NoteCell findingId={finding.id} initialNote={finding.note} />
|
||
</td>
|
||
);
|
||
default:
|
||
return <td style={{ padding: '0.45rem 0.75rem', color: '#64748B' }}>—</td>;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AddToQueuePopover — portal-based popover for adding a finding to the queue
|
||
// ---------------------------------------------------------------------------
|
||
function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd, onCancel }) {
|
||
const panelRef = useRef(null);
|
||
const inputRef = useRef(null);
|
||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||
|
||
useEffect(() => {
|
||
if (!anchorRect) return;
|
||
const PANEL_W = 260;
|
||
const PANEL_H = 360; // conservative estimate (3 workflow buttons)
|
||
const spaceBelow = window.innerHeight - anchorRect.bottom - 6;
|
||
const top = spaceBelow >= PANEL_H
|
||
? anchorRect.bottom + 6
|
||
: Math.max(8, anchorRect.top - PANEL_H - 6);
|
||
const left = Math.min(anchorRect.left, window.innerWidth - PANEL_W - 8);
|
||
setPos({ top, left });
|
||
setTimeout(() => inputRef.current?.focus(), 0);
|
||
}, [anchorRect]);
|
||
|
||
// Close on outside click
|
||
useEffect(() => {
|
||
const handler = (e) => {
|
||
if (panelRef.current && !panelRef.current.contains(e.target)) onCancel();
|
||
};
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [onCancel]);
|
||
|
||
// Close on Escape
|
||
useEffect(() => {
|
||
const handler = (e) => { if (e.key === 'Escape') onCancel(); };
|
||
document.addEventListener('keydown', handler);
|
||
return () => document.removeEventListener('keydown', handler);
|
||
}, [onCancel]);
|
||
|
||
const isCard = queueForm.workflowType === 'CARD';
|
||
const canSubmit = isCard || queueForm.vendor.trim().length > 0;
|
||
|
||
return ReactDOM.createPortal(
|
||
<div
|
||
ref={panelRef}
|
||
style={{
|
||
position: 'fixed', top: pos.top, left: pos.left,
|
||
width: '260px', zIndex: 9999,
|
||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||
border: '1px solid rgba(14,165,233,0.35)',
|
||
borderRadius: '0.5rem',
|
||
boxShadow: '0 8px 32px rgba(0,0,0,0.8)',
|
||
padding: '0.875rem',
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem', paddingBottom: '0.5rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||
Add to Ivanti Queue
|
||
</div>
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#94A3B8', marginBottom: '0.75rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.id}>
|
||
{finding.id}
|
||
</div>
|
||
|
||
{/* Vendor input — hidden for CARD */}
|
||
{isCard ? (
|
||
<div style={{
|
||
marginBottom: '0.625rem', padding: '0.4rem 0.5rem',
|
||
background: 'rgba(16,185,129,0.06)',
|
||
border: '1px solid rgba(16,185,129,0.2)',
|
||
borderRadius: '0.25rem',
|
||
fontFamily: 'monospace', fontSize: '0.68rem', color: '#10B981',
|
||
}}>
|
||
No vendor required — disposition handled in CARD
|
||
</div>
|
||
) : (
|
||
<label style={{ display: 'block', marginBottom: '0.625rem' }}>
|
||
<span style={{ display: 'block', fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.3rem' }}>
|
||
Vendor / Platform
|
||
</span>
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={queueForm.vendor}
|
||
onChange={(e) => setQueueForm((f) => ({ ...f, vendor: e.target.value }))}
|
||
placeholder="Juniper, Cisco, ADTRAN…"
|
||
style={{
|
||
width: '100%', boxSizing: 'border-box',
|
||
background: 'rgba(14,165,233,0.05)',
|
||
border: '1px solid rgba(14,165,233,0.2)',
|
||
borderRadius: '0.25rem', padding: '0.35rem 0.5rem',
|
||
color: '#CBD5E1', fontSize: '0.78rem',
|
||
fontFamily: 'monospace', outline: 'none',
|
||
}}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' && canSubmit) onAdd(); }}
|
||
/>
|
||
</label>
|
||
)}
|
||
|
||
{/* Workflow type toggle */}
|
||
<div style={{ marginBottom: '0.875rem' }}>
|
||
<span style={{ display: 'block', fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.3rem' }}>
|
||
Workflow Type
|
||
</span>
|
||
<div style={{ display: 'flex', gap: '0.375rem' }}>
|
||
{[
|
||
{ key: 'FP', col: '#F59E0B', rgb: '245,158,11' },
|
||
{ key: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
|
||
{ key: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||
].map(({ key, col, rgb }) => {
|
||
const active = queueForm.workflowType === key;
|
||
return (
|
||
<button
|
||
key={key}
|
||
onClick={() => setQueueForm((f) => ({ ...f, workflowType: key }))}
|
||
style={{
|
||
flex: 1, padding: '0.3rem',
|
||
background: active ? `rgba(${rgb},0.15)` : 'transparent',
|
||
border: `1px solid ${active ? col : 'rgba(255,255,255,0.1)'}`,
|
||
borderRadius: '0.25rem',
|
||
color: active ? col : '#475569',
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
|
||
cursor: 'pointer', transition: 'all 0.12s',
|
||
}}
|
||
>
|
||
{key}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||
<button
|
||
onClick={onAdd}
|
||
disabled={!canSubmit}
|
||
style={{
|
||
flex: 1, padding: '0.4rem',
|
||
background: canSubmit ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.05)',
|
||
border: `1px solid ${canSubmit ? 'rgba(14,165,233,0.4)' : 'rgba(14,165,233,0.1)'}`,
|
||
borderRadius: '0.25rem',
|
||
color: canSubmit ? '#0EA5E9' : '#334155',
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
|
||
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
}}
|
||
>
|
||
Add to Queue
|
||
</button>
|
||
<button
|
||
onClick={onCancel}
|
||
style={{
|
||
padding: '0.4rem 0.625rem',
|
||
background: 'none', border: 'none',
|
||
color: '#475569', fontFamily: 'monospace', fontSize: '0.72rem',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
|
||
// ---------------------------------------------------------------------------
|
||
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission }) {
|
||
const pendingCount = items.filter((i) => i.status === 'pending').length;
|
||
const completedCount = items.filter((i) => i.status === 'complete').length;
|
||
|
||
const [selectedIds, setSelectedIds] = useState(new Set());
|
||
const [redirectItem, setRedirectItem] = useState(null);
|
||
const [redirectSuccess, setRedirectSuccess] = useState(null);
|
||
|
||
// Drop any selected IDs that no longer exist in items
|
||
useEffect(() => {
|
||
setSelectedIds((prev) => {
|
||
if (prev.size === 0) return prev;
|
||
const valid = new Set(items.map((i) => i.id));
|
||
const next = new Set([...prev].filter((id) => valid.has(id)));
|
||
return next.size === prev.size ? prev : next;
|
||
});
|
||
}, [items]);
|
||
|
||
const toggleSelect = (id) => {
|
||
setSelectedIds((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id); else next.add(id);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const handleDeleteSelected = () => {
|
||
onDeleteMany([...selectedIds]);
|
||
setSelectedIds(new Set());
|
||
};
|
||
|
||
const handleRedirectSuccess = (newItem) => {
|
||
if (onRedirectComplete) onRedirectComplete(newItem);
|
||
setRedirectItem(null);
|
||
setRedirectSuccess(`Redirected to ${newItem.workflow_type}`);
|
||
setTimeout(() => setRedirectSuccess(null), 3000);
|
||
};
|
||
|
||
// CARD items are their own top section; everything else groups by vendor
|
||
const grouped = useMemo(() => {
|
||
const cardItems = items.filter((i) => i.workflow_type === 'CARD');
|
||
const otherItems = items.filter((i) => i.workflow_type !== 'CARD');
|
||
|
||
const map = {};
|
||
otherItems.forEach((item) => {
|
||
const v = item.vendor || 'Unknown';
|
||
if (!map[v]) map[v] = [];
|
||
map[v].push(item);
|
||
});
|
||
const vendorGroups = Object.keys(map).sort().map((vendor) => ({
|
||
key: vendor, label: vendor, items: map[vendor], isCard: false,
|
||
}));
|
||
|
||
return cardItems.length > 0
|
||
? [{ key: '__CARD__', label: 'CARD', items: cardItems, isCard: true }, ...vendorGroups]
|
||
: vendorGroups;
|
||
}, [items]);
|
||
|
||
return (
|
||
<>
|
||
{/* Backdrop */}
|
||
{open && (
|
||
<div
|
||
onClick={onClose}
|
||
style={{
|
||
position: 'fixed', inset: 0,
|
||
background: 'rgba(0,0,0,0.45)',
|
||
zIndex: 9998,
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Panel */}
|
||
<div
|
||
style={{
|
||
position: 'fixed', top: 0, right: 0,
|
||
height: '100vh', width: '420px',
|
||
zIndex: 9999,
|
||
display: 'flex', flexDirection: 'column',
|
||
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
|
||
borderLeft: '1px solid rgba(14,165,233,0.2)',
|
||
boxShadow: '-8px 0 40px rgba(0,0,0,0.7)',
|
||
transform: open ? 'translateX(0)' : 'translateX(100%)',
|
||
transition: 'transform 0.25s cubic-bezier(0.4,0,0.2,1)',
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '1rem 1.25rem',
|
||
borderBottom: '1px solid rgba(14,165,233,0.15)',
|
||
flexShrink: 0,
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||
<ListTodo style={{ width: '18px', height: '18px', color: '#0EA5E9' }} />
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: '#E2E8F0', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||
Ivanti Queue
|
||
</span>
|
||
{pendingCount > 0 && (
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||
minWidth: '20px', height: '20px', padding: '0 5px',
|
||
background: 'rgba(14,165,233,0.2)',
|
||
border: '1px solid rgba(14,165,233,0.4)',
|
||
borderRadius: '999px',
|
||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700', color: '#0EA5E9',
|
||
}}>
|
||
{pendingCount}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}
|
||
>
|
||
<X style={{ width: '18px', height: '18px' }} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: '0.75rem 1.25rem' }}>
|
||
{items.length === 0 ? (
|
||
<div style={{ textAlign: 'center', padding: '3rem 0', fontFamily: 'monospace', fontSize: '0.75rem', color: '#334155' }}>
|
||
No items in queue.<br />
|
||
<span style={{ fontSize: '0.68rem', color: '#1E293B', marginTop: '0.5rem', display: 'block' }}>
|
||
Check a row in the findings table to add it.
|
||
</span>
|
||
</div>
|
||
) : grouped.map(({ key, label, items: groupItems, isCard }) => (
|
||
<div key={key} style={{ marginBottom: '1.25rem' }}>
|
||
{/* Group header */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '0.3rem 0', marginBottom: '0.375rem',
|
||
borderBottom: `1px solid ${isCard ? 'rgba(16,185,129,0.2)' : 'rgba(255,255,255,0.06)'}`,
|
||
}}>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: isCard ? '#10B981' : '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||
{label}
|
||
</span>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||
{groupItems.length}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Items */}
|
||
{groupItems.map((item) => {
|
||
const done = item.status === 'complete';
|
||
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
|
||
: item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' }
|
||
: { col: '#10B981', rgb: '16,185,129' };
|
||
const cves = item.cves || [];
|
||
const cveDisplay = cves.length > 0
|
||
? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '')
|
||
: '—';
|
||
const isCardItem = item.workflow_type === 'CARD';
|
||
return (
|
||
<div
|
||
key={item.id}
|
||
style={{
|
||
display: 'flex', alignItems: 'flex-start', gap: '0.5rem',
|
||
padding: '0.5rem 0.625rem',
|
||
marginBottom: '0.25rem',
|
||
borderRadius: '0.375rem',
|
||
background: done ? 'rgba(16,185,129,0.04)' : 'rgba(14,165,233,0.04)',
|
||
border: `1px solid ${done ? 'rgba(16,185,129,0.12)' : 'rgba(14,165,233,0.1)'}`,
|
||
opacity: done ? 0.55 : 1,
|
||
transition: 'opacity 0.15s',
|
||
}}
|
||
>
|
||
{/* Selection checkbox — for bulk delete */}
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedIds.has(item.id)}
|
||
onChange={() => toggleSelect(item.id)}
|
||
style={{ accentColor: '#EF4444', width: '12px', height: '12px', flexShrink: 0, marginTop: '3px', cursor: 'pointer', opacity: selectedIds.has(item.id) ? 1 : 0.35 }}
|
||
title="Select for deletion"
|
||
/>
|
||
|
||
{/* Complete checkbox */}
|
||
<input
|
||
type="checkbox"
|
||
checked={done}
|
||
onChange={() => onUpdate(item.id, { status: done ? 'pending' : 'complete' })}
|
||
style={{ accentColor: '#10B981', width: '14px', height: '14px', flexShrink: 0, marginTop: '2px', cursor: 'pointer' }}
|
||
/>
|
||
|
||
{/* Content */}
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||
color: done ? '#475569' : '#CBD5E1',
|
||
textDecoration: done ? 'line-through' : 'none',
|
||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||
}} title={item.finding_id}>
|
||
{item.finding_id}
|
||
</div>
|
||
{isCardItem ? (
|
||
<>
|
||
{item.hostname && (
|
||
<div style={{
|
||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||
color: done ? '#334155' : '#94A3B8',
|
||
textDecoration: done ? 'line-through' : 'none',
|
||
marginTop: '2px',
|
||
}}>
|
||
{item.hostname}
|
||
</div>
|
||
)}
|
||
{item.ip_address && (
|
||
<div style={{
|
||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||
color: done ? '#334155' : '#10B981',
|
||
textDecoration: done ? 'line-through' : 'none',
|
||
marginTop: '2px',
|
||
}}>
|
||
{item.ip_address}
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
{cves.length > 0 && (
|
||
<div style={{
|
||
fontFamily: 'monospace', fontSize: '0.62rem',
|
||
color: done ? '#334155' : '#64748B',
|
||
textDecoration: done ? 'line-through' : 'none',
|
||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||
marginTop: '1px',
|
||
}} title={cves.join(', ')}>
|
||
{cveDisplay}
|
||
</div>
|
||
)}
|
||
{item.hostname && (
|
||
<div style={{
|
||
fontFamily: 'monospace', fontSize: '0.62rem',
|
||
color: done ? '#334155' : '#94A3B8',
|
||
textDecoration: done ? 'line-through' : 'none',
|
||
marginTop: '1px',
|
||
}}>
|
||
{item.hostname}
|
||
</div>
|
||
)}
|
||
{item.ip_address && (
|
||
<div style={{
|
||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||
color: done ? '#334155' : '#10B981',
|
||
textDecoration: done ? 'line-through' : 'none',
|
||
marginTop: '1px',
|
||
}}>
|
||
{item.ip_address}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Workflow type badge */}
|
||
<span style={{
|
||
flexShrink: 0,
|
||
padding: '0.1rem 0.35rem',
|
||
borderRadius: '0.2rem',
|
||
background: `rgba(${wfColor.rgb},0.12)`,
|
||
border: `1px solid rgba(${wfColor.rgb},0.3)`,
|
||
color: wfColor.col,
|
||
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
|
||
}}>
|
||
{item.workflow_type}
|
||
</span>
|
||
|
||
{/* Redirect button — completed items only */}
|
||
{canWrite && done && (
|
||
<button
|
||
onClick={() => setRedirectItem(item)}
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||
onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'}
|
||
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
|
||
title="Redirect to another workflow"
|
||
>
|
||
<CornerUpRight style={{ width: '13px', height: '13px' }} />
|
||
</button>
|
||
)}
|
||
|
||
{/* Delete button */}
|
||
<button
|
||
onClick={() => onDelete(item.id)}
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||
onMouseEnter={(e) => e.currentTarget.style.color = '#EF4444'}
|
||
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
|
||
title="Remove from queue"
|
||
>
|
||
<Trash2 style={{ width: '13px', height: '13px' }} />
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Submissions section */}
|
||
{fpSubmissions && fpSubmissions.length > 0 && (
|
||
<div style={{ padding: '0 1.25rem 0.75rem' }}>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '0.3rem 0', marginBottom: '0.375rem',
|
||
borderBottom: '1px solid rgba(245,158,11,0.2)',
|
||
}}>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||
Submissions
|
||
</span>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||
{fpSubmissions.length}
|
||
</span>
|
||
</div>
|
||
{fpSubmissions.map((sub) => {
|
||
const lsBadge = lifecycleStatusBadge(sub.lifecycle_status);
|
||
const findingCount = (() => {
|
||
try { return JSON.parse(sub.finding_ids_json || '[]').length; } catch { return 0; }
|
||
})();
|
||
const clickable = canWrite && onEditSubmission;
|
||
return (
|
||
<div
|
||
key={sub.id}
|
||
onClick={clickable ? () => onEditSubmission(sub) : undefined}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||
padding: '0.45rem 0.625rem',
|
||
marginBottom: '0.25rem',
|
||
borderRadius: '0.375rem',
|
||
background: 'rgba(245,158,11,0.04)',
|
||
border: '1px solid rgba(245,158,11,0.1)',
|
||
cursor: clickable ? 'pointer' : 'default',
|
||
transition: 'all 0.15s',
|
||
}}
|
||
onMouseEnter={clickable ? (e) => {
|
||
e.currentTarget.style.borderColor = 'rgba(245,158,11,0.3)';
|
||
e.currentTarget.style.background = 'rgba(245,158,11,0.08)';
|
||
} : undefined}
|
||
onMouseLeave={clickable ? (e) => {
|
||
e.currentTarget.style.borderColor = 'rgba(245,158,11,0.1)';
|
||
e.currentTarget.style.background = 'rgba(245,158,11,0.04)';
|
||
} : undefined}
|
||
>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||
color: '#CBD5E1',
|
||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||
}} title={sub.workflow_name}>
|
||
{sub.workflow_name || `Batch ${sub.ivanti_workflow_batch_id}`}
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '2px' }}>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' }}>
|
||
#{sub.ivanti_workflow_batch_id}
|
||
</span>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#475569' }}>
|
||
{findingCount} finding{findingCount !== 1 ? 's' : ''}
|
||
</span>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||
{sub.created_at ? new Date(sub.created_at).toLocaleDateString() : ''}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<span style={{
|
||
flexShrink: 0,
|
||
padding: '0.1rem 0.35rem',
|
||
borderRadius: '0.2rem',
|
||
background: lsBadge.bg,
|
||
border: `1px solid ${lsBadge.border}`,
|
||
color: lsBadge.text,
|
||
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
|
||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||
}}>
|
||
{sub.lifecycle_status || 'submitted'}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer */}
|
||
<div style={{
|
||
padding: '0.75rem 1.25rem',
|
||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||
flexShrink: 0,
|
||
display: 'flex', gap: '0.5rem',
|
||
}}>
|
||
{/* Create FP Workflow — visible for editor/admin only */}
|
||
{canWrite && (() => {
|
||
const fpEnabled = isCreateFpButtonEnabled(items, selectedIds);
|
||
return (
|
||
<button
|
||
onClick={() => onCreateFpWorkflow([...selectedIds])}
|
||
disabled={!fpEnabled}
|
||
title={!fpEnabled ? 'Select pending FP items to create a workflow' : ''}
|
||
style={{
|
||
flex: 1, padding: '0.45rem',
|
||
background: fpEnabled ? 'rgba(245,158,11,0.12)' : 'transparent',
|
||
border: `1px solid ${fpEnabled ? 'rgba(245,158,11,0.35)' : 'rgba(255,255,255,0.05)'}`,
|
||
borderRadius: '0.375rem',
|
||
color: fpEnabled ? '#F59E0B' : '#334155',
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||
cursor: fpEnabled ? 'pointer' : 'not-allowed',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
transition: 'all 0.12s',
|
||
}}
|
||
>
|
||
Create FP Workflow
|
||
</button>
|
||
);
|
||
})()}
|
||
{/* Delete selected — only shown when items are selected */}
|
||
{selectedIds.size > 0 && (
|
||
<button
|
||
onClick={handleDeleteSelected}
|
||
style={{
|
||
flex: 1, padding: '0.45rem',
|
||
background: 'rgba(239,68,68,0.1)',
|
||
border: '1px solid rgba(239,68,68,0.35)',
|
||
borderRadius: '0.375rem',
|
||
color: '#EF4444',
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||
cursor: 'pointer',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
transition: 'all 0.12s',
|
||
}}
|
||
>
|
||
Delete ({selectedIds.size})
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={onClearCompleted}
|
||
disabled={completedCount === 0}
|
||
style={{
|
||
flex: 1, padding: '0.45rem',
|
||
background: completedCount > 0 ? 'rgba(16,185,129,0.08)' : 'transparent',
|
||
border: `1px solid ${completedCount > 0 ? 'rgba(16,185,129,0.25)' : 'rgba(255,255,255,0.05)'}`,
|
||
borderRadius: '0.375rem',
|
||
color: completedCount > 0 ? '#10B981' : '#334155',
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||
cursor: completedCount > 0 ? 'pointer' : 'not-allowed',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
transition: 'all 0.12s',
|
||
}}
|
||
>
|
||
Clear Completed {completedCount > 0 ? `(${completedCount})` : ''}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Redirect success notification */}
|
||
{redirectSuccess && (
|
||
<div style={{
|
||
position: 'fixed', top: '1rem', right: '440px',
|
||
zIndex: 10001,
|
||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||
padding: '0.5rem 1rem',
|
||
background: 'rgba(16, 185, 129, 0.15)',
|
||
border: '1px solid rgba(16, 185, 129, 0.4)',
|
||
borderRadius: '0.375rem',
|
||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||
fontFamily: "'JetBrains Mono', monospace",
|
||
fontSize: '0.75rem', fontWeight: '600',
|
||
color: '#10B981',
|
||
}}>
|
||
<Check style={{ width: '14px', height: '14px' }} />
|
||
{redirectSuccess}
|
||
</div>
|
||
)}
|
||
|
||
{/* Redirect modal */}
|
||
{redirectItem && (
|
||
<RedirectModal
|
||
item={redirectItem}
|
||
onClose={() => setRedirectItem(null)}
|
||
onRedirect={handleRedirectSuccess}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// FP Workflow helpers (pure functions, exported for testing)
|
||
// ---------------------------------------------------------------------------
|
||
function isCreateFpButtonEnabled(items, selectedIds) {
|
||
return items.some(item =>
|
||
selectedIds.has(item.id) &&
|
||
item.workflow_type === 'FP' &&
|
||
item.status === 'pending'
|
||
);
|
||
}
|
||
|
||
function filterFpItems(items) {
|
||
return items.filter(item => item.workflow_type === 'FP');
|
||
}
|
||
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// FpWorkflowModal — submit FP workflows to Ivanti API
|
||
// ---------------------------------------------------------------------------
|
||
const ALLOWED_EXTENSIONS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'];
|
||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||
|
||
function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||
const [name, setName] = useState('');
|
||
const [reason, setReason] = useState('');
|
||
const [description, setDescription] = useState('');
|
||
const [expirationDate, setExpirationDate] = useState('');
|
||
const [scopeOverride, setScopeOverride] = useState('Authorized');
|
||
const [files, setFiles] = useState([]);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [progress, setProgress] = useState({ step: '', current: 0, total: 0 });
|
||
const [errors, setErrors] = useState({});
|
||
const [result, setResult] = useState(null);
|
||
const fileInputRef = useRef(null);
|
||
const dropRef = useRef(null);
|
||
|
||
// Reset form when modal opens
|
||
useEffect(() => {
|
||
if (open) {
|
||
setName('');
|
||
setReason('');
|
||
setDescription('');
|
||
setExpirationDate('');
|
||
setScopeOverride('Authorized');
|
||
setFiles([]);
|
||
setSubmitting(false);
|
||
setProgress({ step: '', current: 0, total: 0 });
|
||
setErrors({});
|
||
setResult(null);
|
||
}
|
||
}, [open]);
|
||
|
||
// Close on Escape
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const handler = (e) => { if (e.key === 'Escape' && !submitting) onClose(); };
|
||
document.addEventListener('keydown', handler);
|
||
return () => document.removeEventListener('keydown', handler);
|
||
}, [open, submitting, onClose]);
|
||
|
||
const isAllowedExtension = (filename) => {
|
||
const ext = '.' + filename.split('.').pop().toLowerCase();
|
||
return ALLOWED_EXTENSIONS.includes(ext);
|
||
};
|
||
|
||
const addFiles = (newFiles) => {
|
||
const fileErrors = [];
|
||
const valid = [];
|
||
Array.from(newFiles).forEach(f => {
|
||
if (!isAllowedExtension(f.name)) {
|
||
fileErrors.push(`"${f.name}" — file type not allowed. Accepted: ${ALLOWED_EXTENSIONS.join(', ')}`);
|
||
} else if (f.size > MAX_FILE_SIZE) {
|
||
fileErrors.push(`"${f.name}" — exceeds 10 MB limit`);
|
||
} else {
|
||
valid.push(f);
|
||
}
|
||
});
|
||
if (fileErrors.length) {
|
||
setErrors(prev => ({ ...prev, files: fileErrors.join('; ') }));
|
||
} else {
|
||
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
|
||
}
|
||
if (valid.length) setFiles(prev => [...prev, ...valid]);
|
||
};
|
||
|
||
const removeFile = (idx) => {
|
||
setFiles(prev => prev.filter((_, i) => i !== idx));
|
||
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
|
||
};
|
||
|
||
const handleDrop = (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
|
||
};
|
||
|
||
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); };
|
||
|
||
const validate = () => {
|
||
const errs = {};
|
||
if (!name.trim()) errs.name = 'Workflow name is required';
|
||
else if (name.trim().length > 255) errs.name = 'Name must be 255 characters or fewer';
|
||
if (!reason.trim()) errs.reason = 'Reason is required';
|
||
if (description.length > 2000) errs.description = 'Description must be 2000 characters or fewer';
|
||
if (!expirationDate) errs.expirationDate = 'Expiration date is required';
|
||
else {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const exp = new Date(expirationDate + 'T00:00:00');
|
||
if (exp <= today) errs.expirationDate = 'Expiration date must be in the future';
|
||
}
|
||
setErrors(errs);
|
||
return Object.keys(errs).length === 0;
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!validate()) return;
|
||
setSubmitting(true);
|
||
setProgress({ step: 'Creating workflow...', current: 0, total: 0 });
|
||
setResult(null);
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('name', name.trim());
|
||
formData.append('reason', reason.trim());
|
||
if (description.trim()) formData.append('description', description.trim());
|
||
formData.append('expirationDate', expirationDate);
|
||
formData.append('scopeOverride', scopeOverride);
|
||
formData.append('findingIds', JSON.stringify(selectedItems.map(i => i.finding_id)));
|
||
formData.append('queueItemIds', JSON.stringify(selectedItems.map(i => i.id)));
|
||
files.forEach(f => formData.append('attachments', f));
|
||
|
||
if (files.length > 0) {
|
||
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: files.length });
|
||
}
|
||
|
||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
body: formData,
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (res.ok && data.success) {
|
||
setResult({
|
||
success: true,
|
||
workflowBatchId: data.workflowBatchId,
|
||
generatedId: data.generatedId,
|
||
attachmentResults: data.attachmentResults || [],
|
||
status: data.status || 'success',
|
||
});
|
||
onSuccess();
|
||
} else {
|
||
let errorMsg = data.error || 'Workflow creation failed';
|
||
if (res.status === 401) errorMsg = 'Ivanti API key is invalid or missing. Contact your administrator.';
|
||
else if (res.status === 429) errorMsg = 'Ivanti API rate limit reached. Please try again in a few minutes.';
|
||
|
||
setResult({
|
||
success: false,
|
||
error: errorMsg,
|
||
workflowBatchId: data.workflowBatchId || null,
|
||
generatedId: data.generatedId || null,
|
||
attachmentResults: data.attachmentResults || [],
|
||
status: data.status || 'failed',
|
||
});
|
||
}
|
||
} catch (err) {
|
||
setResult({
|
||
success: false,
|
||
error: err.message || 'Network error — could not reach the server',
|
||
status: 'failed',
|
||
});
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
if (!open) return null;
|
||
|
||
const formatSize = (bytes) => {
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
};
|
||
|
||
// ---- Styles ----
|
||
const overlayStyle = {
|
||
position: 'fixed', inset: 0, zIndex: 10000,
|
||
background: 'rgba(0,0,0,0.6)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
};
|
||
const modalStyle = {
|
||
width: '640px', maxHeight: '90vh', overflow: 'auto',
|
||
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
|
||
border: '1px solid rgba(245,158,11,0.3)',
|
||
borderRadius: '0.75rem',
|
||
boxShadow: '0 12px 48px rgba(0,0,0,0.8)',
|
||
fontFamily: 'monospace',
|
||
};
|
||
const headerStyle = {
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '1rem 1.25rem',
|
||
borderBottom: '1px solid rgba(245,158,11,0.2)',
|
||
};
|
||
const sectionStyle = {
|
||
padding: '0.875rem 1.25rem',
|
||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||
};
|
||
const labelStyle = {
|
||
display: 'block', fontSize: '0.68rem', fontWeight: '600',
|
||
color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||
marginBottom: '0.35rem',
|
||
};
|
||
const inputStyle = {
|
||
width: '100%', boxSizing: 'border-box',
|
||
background: 'rgba(14,165,233,0.05)',
|
||
border: '1px solid rgba(14,165,233,0.2)',
|
||
borderRadius: '0.25rem', padding: '0.45rem 0.6rem',
|
||
color: '#CBD5E1', fontSize: '0.82rem', fontFamily: 'monospace',
|
||
outline: 'none',
|
||
};
|
||
const inputErrorStyle = { ...inputStyle, borderColor: '#EF4444' };
|
||
const textareaStyle = { ...inputStyle, minHeight: '60px', resize: 'vertical' };
|
||
const textareaErrorStyle = { ...textareaStyle, borderColor: '#EF4444' };
|
||
const errorTextStyle = { fontSize: '0.68rem', color: '#EF4444', marginTop: '0.2rem' };
|
||
const footerStyle = {
|
||
display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.625rem',
|
||
padding: '0.875rem 1.25rem',
|
||
};
|
||
|
||
// ---- Result views ----
|
||
if (result) {
|
||
return ReactDOM.createPortal(
|
||
<div style={overlayStyle} onClick={() => { if (!submitting) onClose(); }}>
|
||
<div style={modalStyle} onClick={e => e.stopPropagation()}>
|
||
<div style={headerStyle}>
|
||
<span style={{ fontSize: '0.88rem', fontWeight: '700', color: result.success ? '#10B981' : '#EF4444' }}>
|
||
{result.success ? 'Workflow Created' : 'Submission Failed'}
|
||
</span>
|
||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
<div style={{ padding: '1.5rem 1.25rem', textAlign: 'center' }}>
|
||
{result.success ? (
|
||
<>
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<Check size={36} style={{ color: '#10B981' }} />
|
||
</div>
|
||
<div style={{ fontSize: '1.1rem', fontWeight: '700', color: '#F59E0B', marginBottom: '0.5rem' }}>
|
||
{result.generatedId || `Batch #${result.workflowBatchId}`}
|
||
</div>
|
||
<div style={{ fontSize: '0.78rem', color: '#94A3B8', marginBottom: '1rem' }}>
|
||
FP workflow created successfully with {selectedItems.length} finding{selectedItems.length !== 1 ? 's' : ''}.
|
||
</div>
|
||
{result.attachmentResults.length > 0 && (
|
||
<div style={{ textAlign: 'left', marginBottom: '1rem' }}>
|
||
<div style={labelStyle}>Attachments</div>
|
||
{result.attachmentResults.map((a, i) => (
|
||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
|
||
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
|
||
<span>{a.filename}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<AlertTriangle size={36} style={{ color: '#EF4444' }} />
|
||
</div>
|
||
<div style={{ fontSize: '0.88rem', fontWeight: '600', color: '#E2E8F0', marginBottom: '0.5rem' }}>
|
||
{result.error}
|
||
</div>
|
||
{result.generatedId && (
|
||
<div style={{ fontSize: '0.78rem', color: '#F59E0B', marginBottom: '0.5rem' }}>
|
||
Workflow was created: {result.generatedId}
|
||
</div>
|
||
)}
|
||
{result.attachmentResults?.length > 0 && (
|
||
<div style={{ textAlign: 'left', marginBottom: '1rem' }}>
|
||
<div style={labelStyle}>Attachment Results</div>
|
||
{result.attachmentResults.map((a, i) => (
|
||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
|
||
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
|
||
<span>{a.filename}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
<div style={footerStyle}>
|
||
{!result.success && (
|
||
<button
|
||
onClick={() => setResult(null)}
|
||
style={{
|
||
padding: '0.45rem 1rem',
|
||
background: 'rgba(245,158,11,0.1)',
|
||
border: '1px solid rgba(245,158,11,0.3)',
|
||
borderRadius: '0.375rem',
|
||
color: '#F59E0B', fontSize: '0.78rem', fontWeight: '600',
|
||
cursor: 'pointer', fontFamily: 'monospace',
|
||
}}
|
||
>
|
||
Retry
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
padding: '0.45rem 1rem',
|
||
background: result.success ? 'rgba(16,185,129,0.12)' : 'rgba(255,255,255,0.04)',
|
||
border: `1px solid ${result.success ? 'rgba(16,185,129,0.3)' : 'rgba(255,255,255,0.1)'}`,
|
||
borderRadius: '0.375rem',
|
||
color: result.success ? '#10B981' : '#94A3B8',
|
||
fontSize: '0.78rem', fontWeight: '600',
|
||
cursor: 'pointer', fontFamily: 'monospace',
|
||
}}
|
||
>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|
||
|
||
// ---- Form view ----
|
||
return ReactDOM.createPortal(
|
||
<div style={overlayStyle} onClick={() => { if (!submitting) onClose(); }}>
|
||
<div style={modalStyle} onClick={e => e.stopPropagation()}>
|
||
{/* Header */}
|
||
<div style={headerStyle}>
|
||
<span style={{ fontSize: '0.88rem', fontWeight: '700', color: '#F59E0B' }}>
|
||
Create FP Workflow
|
||
</span>
|
||
<button onClick={() => { if (!submitting) onClose(); }} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Selected findings summary */}
|
||
<div style={sectionStyle}>
|
||
<div style={labelStyle}>Selected Findings ({selectedItems.length})</div>
|
||
<div style={{ maxHeight: '120px', overflow: 'auto' }}>
|
||
{selectedItems.map((item, i) => (
|
||
<div key={item.id || i} style={{ display: 'flex', gap: '0.5rem', alignItems: 'baseline', fontSize: '0.75rem', color: '#94A3B8', marginBottom: '0.3rem' }}>
|
||
<span style={{ color: '#F59E0B', fontWeight: '600', flexShrink: 0 }}>{item.finding_id}</span>
|
||
<span style={{ color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{item.finding_title || '—'}</span>
|
||
{item.cves_json && (() => {
|
||
try {
|
||
const cves = JSON.parse(item.cves_json);
|
||
return cves.length > 0 ? <span style={{ color: '#64748B', flexShrink: 0 }}>{cves.join(', ')}</span> : null;
|
||
} catch { return null; }
|
||
})()}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Form fields */}
|
||
<div style={sectionStyle}>
|
||
{/* Name */}
|
||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||
<span style={labelStyle}>Workflow Name <span style={{ color: '#EF4444' }}>*</span></span>
|
||
<input
|
||
type="text"
|
||
value={name}
|
||
onChange={e => setName(e.target.value)}
|
||
placeholder="FP — CVE-2024-XXXX — Vendor"
|
||
disabled={submitting}
|
||
maxLength={255}
|
||
style={errors.name ? inputErrorStyle : inputStyle}
|
||
/>
|
||
{errors.name && <div style={errorTextStyle}>{errors.name}</div>}
|
||
</label>
|
||
|
||
{/* Reason */}
|
||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||
<span style={labelStyle}>Reason / Justification <span style={{ color: '#EF4444' }}>*</span></span>
|
||
<textarea
|
||
value={reason}
|
||
onChange={e => setReason(e.target.value)}
|
||
placeholder="Explain why these findings are false positives..."
|
||
disabled={submitting}
|
||
style={errors.reason ? textareaErrorStyle : textareaStyle}
|
||
/>
|
||
{errors.reason && <div style={errorTextStyle}>{errors.reason}</div>}
|
||
</label>
|
||
|
||
{/* Description */}
|
||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||
<span style={labelStyle}>Description (optional)</span>
|
||
<textarea
|
||
value={description}
|
||
onChange={e => setDescription(e.target.value)}
|
||
placeholder="Additional context or details..."
|
||
disabled={submitting}
|
||
maxLength={2000}
|
||
style={errors.description ? textareaErrorStyle : textareaStyle}
|
||
/>
|
||
{errors.description && <div style={errorTextStyle}>{errors.description}</div>}
|
||
<div style={{ fontSize: '0.62rem', color: '#475569', textAlign: 'right', marginTop: '0.15rem' }}>{description.length}/2000</div>
|
||
</label>
|
||
|
||
{/* Expiration date */}
|
||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||
<span style={labelStyle}>Expiration Date <span style={{ color: '#EF4444' }}>*</span></span>
|
||
<input
|
||
type="date"
|
||
value={expirationDate}
|
||
onChange={e => setExpirationDate(e.target.value)}
|
||
disabled={submitting}
|
||
style={errors.expirationDate ? inputErrorStyle : inputStyle}
|
||
/>
|
||
{errors.expirationDate && <div style={errorTextStyle}>{errors.expirationDate}</div>}
|
||
</label>
|
||
|
||
{/* Scope override toggle */}
|
||
<div style={{ marginBottom: '0.25rem' }}>
|
||
<span style={labelStyle}>Scope Override Authorization</span>
|
||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||
{['Authorized', 'None'].map(val => {
|
||
const active = scopeOverride === val;
|
||
return (
|
||
<button
|
||
key={val}
|
||
onClick={() => setScopeOverride(val)}
|
||
disabled={submitting}
|
||
style={{
|
||
flex: 1, padding: '0.35rem',
|
||
background: active ? 'rgba(245,158,11,0.12)' : 'transparent',
|
||
border: `1px solid ${active ? 'rgba(245,158,11,0.4)' : 'rgba(255,255,255,0.08)'}`,
|
||
borderRadius: '0.25rem',
|
||
color: active ? '#F59E0B' : '#475569',
|
||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||
transition: 'all 0.12s',
|
||
}}
|
||
>
|
||
{val}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* File upload */}
|
||
<div style={sectionStyle}>
|
||
<div style={labelStyle}>Attachments</div>
|
||
<div
|
||
ref={dropRef}
|
||
onDrop={handleDrop}
|
||
onDragOver={handleDragOver}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
style={{
|
||
border: '1px dashed rgba(14,165,233,0.25)',
|
||
borderRadius: '0.375rem',
|
||
padding: '1rem',
|
||
textAlign: 'center',
|
||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||
background: 'rgba(14,165,233,0.03)',
|
||
transition: 'border-color 0.15s',
|
||
}}
|
||
>
|
||
<Upload size={20} style={{ color: '#475569', marginBottom: '0.35rem' }} />
|
||
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
||
Drop files here or click to browse
|
||
</div>
|
||
<div style={{ fontSize: '0.62rem', color: '#475569', marginTop: '0.2rem' }}>
|
||
Max 10 MB per file · PDF, PNG, JPG, DOC, XLSX, CSV, TXT, ZIP
|
||
</div>
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
style={{ display: 'none' }}
|
||
onChange={e => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ''; }}
|
||
accept={ALLOWED_EXTENSIONS.join(',')}
|
||
/>
|
||
{errors.files && <div style={errorTextStyle}>{errors.files}</div>}
|
||
{files.length > 0 && (
|
||
<div style={{ marginTop: '0.5rem' }}>
|
||
{files.map((f, i) => (
|
||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.3rem 0', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
|
||
<FileText size={13} style={{ color: '#64748B', flexShrink: 0 }} />
|
||
<span style={{ flex: 1, fontSize: '0.75rem', color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||
<span style={{ fontSize: '0.68rem', color: '#475569', flexShrink: 0 }}>{formatSize(f.size)}</span>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); removeFile(i); }}
|
||
disabled={submitting}
|
||
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.15rem' }}
|
||
>
|
||
<Trash2 size={12} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div style={footerStyle}>
|
||
{submitting && (
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: '#F59E0B' }}>
|
||
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||
<span>{progress.step}</span>
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={() => { if (!submitting) onClose(); }}
|
||
disabled={submitting}
|
||
style={{
|
||
padding: '0.45rem 1rem',
|
||
background: 'none',
|
||
border: '1px solid rgba(255,255,255,0.08)',
|
||
borderRadius: '0.375rem',
|
||
color: '#64748B', fontSize: '0.78rem', fontWeight: '600',
|
||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||
fontFamily: 'monospace',
|
||
}}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleSubmit}
|
||
disabled={submitting}
|
||
style={{
|
||
padding: '0.45rem 1.25rem',
|
||
background: submitting ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)',
|
||
border: `1px solid ${submitting ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)'}`,
|
||
borderRadius: '0.375rem',
|
||
color: submitting ? '#92700C' : '#F59E0B',
|
||
fontSize: '0.78rem', fontWeight: '700',
|
||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
}}
|
||
>
|
||
{submitting ? 'Submitting...' : 'Submit'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// FpEditModal — edit existing FP submissions (tabbed modal)
|
||
// ---------------------------------------------------------------------------
|
||
function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
||
const [activeTab, setActiveTab] = useState('details');
|
||
const [name, setName] = useState('');
|
||
const [reason, setReason] = useState('');
|
||
const [description, setDescription] = useState('');
|
||
const [expirationDate, setExpirationDate] = useState('');
|
||
const [scopeOverride, setScopeOverride] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [errors, setErrors] = useState({});
|
||
const [result, setResult] = useState(null);
|
||
const [files, setFiles] = useState([]);
|
||
const [additionalFindingIds, setAdditionalFindingIds] = useState(new Set());
|
||
const [statusValue, setStatusValue] = useState('');
|
||
|
||
// Reset form when submission changes
|
||
useEffect(() => {
|
||
if (submission) {
|
||
setName(submission.workflow_name || '');
|
||
setReason(submission.reason || '');
|
||
setDescription(submission.description || '');
|
||
setExpirationDate(submission.expiration_date || '');
|
||
setScopeOverride(submission.scope_override || '');
|
||
setStatusValue(submission.lifecycle_status || 'submitted');
|
||
setActiveTab('details');
|
||
setErrors({});
|
||
setResult(null);
|
||
setFiles([]);
|
||
setAdditionalFindingIds(new Set());
|
||
}
|
||
}, [submission]);
|
||
|
||
if (!open || !submission) return null;
|
||
|
||
const isApproved = (submission.lifecycle_status || '').toLowerCase() === 'approved';
|
||
const currentFindings = (() => {
|
||
try { return JSON.parse(submission.finding_ids_json || '[]'); } catch { return []; }
|
||
})();
|
||
const existingAttachments = (() => {
|
||
try { return JSON.parse(submission.attachment_results_json || '[]'); } catch { return []; }
|
||
})();
|
||
const history = submission.history || [];
|
||
|
||
const pendingFpQueue = (queueItems || []).filter(i =>
|
||
i.workflow_type === 'FP' && i.status === 'pending' && !currentFindings.includes(String(i.finding_id))
|
||
);
|
||
|
||
const handleSaveDetails = async () => {
|
||
setSaving(true); setErrors({}); setResult(null);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}`, {
|
||
method: 'PUT', credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, reason, description, expirationDate, scopeOverride }),
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
setResult({ type: 'success', message: 'Details saved successfully.' });
|
||
if (onSuccess) onSuccess();
|
||
} else {
|
||
setResult({ type: 'error', message: data.error || 'Failed to save details.' });
|
||
}
|
||
} catch (e) {
|
||
setResult({ type: 'error', message: 'Network error saving details.' });
|
||
} finally { setSaving(false); }
|
||
};
|
||
|
||
const handleAddFindings = async () => {
|
||
if (additionalFindingIds.size === 0) return;
|
||
setSaving(true); setResult(null);
|
||
const selectedItems = pendingFpQueue.filter(i => additionalFindingIds.has(i.id));
|
||
const findingIds = selectedItems.map(i => String(i.finding_id));
|
||
const queueItemIds = selectedItems.map(i => i.id);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/findings`, {
|
||
method: 'POST', credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ findingIds, queueItemIds }),
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
setResult({ type: 'success', message: `Added ${findingIds.length} finding(s).` });
|
||
setAdditionalFindingIds(new Set());
|
||
if (onSuccess) onSuccess();
|
||
} else {
|
||
setResult({ type: 'error', message: data.error || 'Failed to add findings.' });
|
||
}
|
||
} catch (e) {
|
||
setResult({ type: 'error', message: 'Network error adding findings.' });
|
||
} finally { setSaving(false); }
|
||
};
|
||
|
||
const handleUploadAttachments = async () => {
|
||
if (files.length === 0) return;
|
||
setSaving(true); setResult(null);
|
||
const formData = new FormData();
|
||
files.forEach(f => formData.append('attachments', f));
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/attachments`, {
|
||
method: 'POST', credentials: 'include', body: formData,
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
const successCount = (data.attachmentResults || []).filter(r => r.success).length;
|
||
setResult({ type: 'success', message: `Uploaded ${successCount} file(s).` });
|
||
setFiles([]);
|
||
if (onSuccess) onSuccess();
|
||
} else {
|
||
setResult({ type: 'error', message: data.error || 'Failed to upload attachments.' });
|
||
}
|
||
} catch (e) {
|
||
setResult({ type: 'error', message: 'Network error uploading attachments.' });
|
||
} finally { setSaving(false); }
|
||
};
|
||
|
||
const handleStatusChange = async (newStatus) => {
|
||
setSaving(true); setResult(null);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/status`, {
|
||
method: 'PATCH', credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ lifecycle_status: newStatus }),
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
setResult({ type: 'success', message: `Status changed to ${newStatus}.` });
|
||
setStatusValue(newStatus);
|
||
if (onSuccess) onSuccess();
|
||
} else {
|
||
setResult({ type: 'error', message: data.error || 'Failed to change status.' });
|
||
}
|
||
} catch (e) {
|
||
setResult({ type: 'error', message: 'Network error changing status.' });
|
||
} finally { setSaving(false); }
|
||
};
|
||
|
||
const lsBadge = lifecycleStatusBadge(statusValue);
|
||
const tabs = ['details', 'findings', 'attachments', 'history'];
|
||
|
||
const inputStyle = {
|
||
width: '100%', boxSizing: 'border-box',
|
||
background: isApproved ? 'rgba(100,116,139,0.06)' : 'rgba(14,165,233,0.05)',
|
||
border: `1px solid ${isApproved ? 'rgba(100,116,139,0.15)' : 'rgba(14,165,233,0.2)'}`,
|
||
borderRadius: '0.25rem', padding: '0.4rem 0.5rem',
|
||
color: isApproved ? '#64748B' : '#CBD5E1',
|
||
fontSize: '0.78rem', fontFamily: 'monospace', outline: 'none',
|
||
};
|
||
const labelStyle = { display: 'block', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#94A3B8', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.06em' };
|
||
|
||
return ReactDOM.createPortal(
|
||
<div style={{ position: 'fixed', inset: 0, zIndex: 10010, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)' }} onClick={onClose}>
|
||
<div onClick={(e) => e.stopPropagation()} style={{
|
||
width: '640px', maxHeight: '85vh', display: 'flex', flexDirection: 'column',
|
||
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
|
||
border: '1px solid rgba(14,165,233,0.2)',
|
||
borderRadius: '0.5rem',
|
||
boxShadow: '0 20px 60px rgba(0,0,0,0.8)',
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid rgba(14,165,233,0.15)', flexShrink: 0 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||
<Edit3 style={{ width: '18px', height: '18px', color: '#F59E0B' }} />
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0' }}>
|
||
Edit FP Workflow
|
||
</span>
|
||
<span style={{
|
||
padding: '0.1rem 0.4rem', borderRadius: '0.2rem',
|
||
background: lsBadge.bg, border: `1px solid ${lsBadge.border}`,
|
||
color: lsBadge.text, fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
|
||
textTransform: 'uppercase',
|
||
}}>
|
||
{statusValue}
|
||
</span>
|
||
</div>
|
||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}>
|
||
<X style={{ width: '18px', height: '18px' }} />
|
||
</button>
|
||
</div>
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#64748B', marginTop: '0.25rem' }}>
|
||
{submission.workflow_name || `Batch #${submission.ivanti_workflow_batch_id}`}
|
||
</div>
|
||
{isApproved && (
|
||
<div style={{ marginTop: '0.5rem', padding: '0.35rem 0.5rem', borderRadius: '0.25rem', background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.2)', fontFamily: 'monospace', fontSize: '0.68rem', color: '#10B981' }}>
|
||
This submission is finalized and cannot be edited.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Tab bar */}
|
||
<div style={{ display: 'flex', borderBottom: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
|
||
{tabs.map(tab => (
|
||
<button key={tab} onClick={() => { setActiveTab(tab); setResult(null); }} style={{
|
||
flex: 1, padding: '0.5rem', background: 'none',
|
||
border: 'none', borderBottom: activeTab === tab ? '2px solid #0EA5E9' : '2px solid transparent',
|
||
color: activeTab === tab ? '#0EA5E9' : '#475569',
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||
cursor: 'pointer', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
transition: 'all 0.12s',
|
||
}}>
|
||
{tab}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Status change row */}
|
||
{!isApproved && (
|
||
<div style={{ padding: '0.5rem 1.25rem', borderBottom: '1px solid rgba(255,255,255,0.04)', display: 'flex', alignItems: 'center', gap: '0.5rem', flexShrink: 0 }}>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#64748B' }}>Status:</span>
|
||
<select
|
||
value={statusValue}
|
||
onChange={(e) => handleStatusChange(e.target.value)}
|
||
disabled={saving}
|
||
style={{
|
||
background: 'rgba(14,165,233,0.05)', border: '1px solid rgba(14,165,233,0.2)',
|
||
borderRadius: '0.25rem', padding: '0.25rem 0.4rem',
|
||
color: '#CBD5E1', fontSize: '0.72rem', fontFamily: 'monospace', outline: 'none',
|
||
cursor: saving ? 'not-allowed' : 'pointer',
|
||
}}
|
||
>
|
||
{['submitted', 'approved', 'rejected', 'rework', 'resubmitted'].map(s => (
|
||
<option key={s} value={s}>{s}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Result banner */}
|
||
{result && (
|
||
<div style={{
|
||
margin: '0.5rem 1.25rem 0', padding: '0.35rem 0.5rem', borderRadius: '0.25rem',
|
||
background: result.type === 'success' ? 'rgba(16,185,129,0.1)' : 'rgba(239,68,68,0.1)',
|
||
border: `1px solid ${result.type === 'success' ? 'rgba(16,185,129,0.3)' : 'rgba(239,68,68,0.3)'}`,
|
||
fontFamily: 'monospace', fontSize: '0.72rem',
|
||
color: result.type === 'success' ? '#10B981' : '#EF4444',
|
||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||
}}>
|
||
{result.type === 'success' ? <Check style={{ width: '12px', height: '12px' }} /> : <AlertCircle style={{ width: '12px', height: '12px' }} />}
|
||
{result.message}
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab content */}
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem 1.25rem' }}>
|
||
{/* Details tab */}
|
||
{activeTab === 'details' && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||
<div>
|
||
<label style={labelStyle}>Workflow Name</label>
|
||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} disabled={isApproved} style={inputStyle} />
|
||
</div>
|
||
<div>
|
||
<label style={labelStyle}>Reason</label>
|
||
<select value={reason} onChange={(e) => setReason(e.target.value)} disabled={isApproved} style={inputStyle}>
|
||
<option value="">Select reason…</option>
|
||
<option value="Scanner false positive">Scanner false positive</option>
|
||
<option value="Compensating control">Compensating control</option>
|
||
<option value="Risk accepted">Risk accepted</option>
|
||
<option value="Not applicable">Not applicable</option>
|
||
<option value="Other">Other</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style={labelStyle}>Description</label>
|
||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} disabled={isApproved} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={labelStyle}>Expiration Date</label>
|
||
<input type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} disabled={isApproved} style={inputStyle} />
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={labelStyle}>Scope Override</label>
|
||
<select value={scopeOverride} onChange={(e) => setScopeOverride(e.target.value)} disabled={isApproved} style={inputStyle}>
|
||
<option value="">Default</option>
|
||
<option value="Authorized">Authorized</option>
|
||
<option value="Unauthorized">Unauthorized</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
{!isApproved && (
|
||
<button onClick={handleSaveDetails} disabled={saving} style={{
|
||
alignSelf: 'flex-end', padding: '0.4rem 1rem',
|
||
background: saving ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)',
|
||
border: `1px solid ${saving ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)'}`,
|
||
borderRadius: '0.375rem', color: saving ? '#92700C' : '#F59E0B',
|
||
fontSize: '0.75rem', fontWeight: '700', cursor: saving ? 'not-allowed' : 'pointer',
|
||
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
}}>
|
||
{saving ? 'Saving…' : 'Save Details'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Findings tab */}
|
||
{activeTab === 'findings' && (
|
||
<div>
|
||
<div style={{ marginBottom: '0.75rem' }}>
|
||
<span style={labelStyle}>Current Findings ({currentFindings.length})</span>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
|
||
{currentFindings.length === 0 ? (
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569' }}>No findings mapped.</span>
|
||
) : currentFindings.map(fid => (
|
||
<span key={fid} style={{
|
||
padding: '0.1rem 0.35rem', borderRadius: '0.2rem',
|
||
background: 'rgba(14,165,233,0.08)', border: '1px solid rgba(14,165,233,0.2)',
|
||
fontFamily: 'monospace', fontSize: '0.65rem', color: '#0EA5E9',
|
||
}}>
|
||
{fid}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{!isApproved && pendingFpQueue.length > 0 && (
|
||
<div>
|
||
<span style={labelStyle}>Add Pending FP Queue Items</span>
|
||
<div style={{ maxHeight: '200px', overflowY: 'auto', marginTop: '0.25rem' }}>
|
||
{pendingFpQueue.map(item => (
|
||
<label key={item.id} style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||
padding: '0.35rem 0.5rem', marginBottom: '0.15rem',
|
||
borderRadius: '0.25rem',
|
||
background: additionalFindingIds.has(item.id) ? 'rgba(245,158,11,0.08)' : 'transparent',
|
||
border: `1px solid ${additionalFindingIds.has(item.id) ? 'rgba(245,158,11,0.2)' : 'rgba(255,255,255,0.04)'}`,
|
||
cursor: 'pointer',
|
||
}}>
|
||
<input type="checkbox" checked={additionalFindingIds.has(item.id)}
|
||
onChange={() => setAdditionalFindingIds(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||
return next;
|
||
})}
|
||
style={{ accentColor: '#F59E0B', width: '13px', height: '13px' }}
|
||
/>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#CBD5E1' }}>{item.finding_id}</span>
|
||
{item.hostname && <span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' }}>{item.hostname}</span>}
|
||
</label>
|
||
))}
|
||
</div>
|
||
<button onClick={handleAddFindings} disabled={saving || additionalFindingIds.size === 0} style={{
|
||
marginTop: '0.5rem', padding: '0.4rem 1rem',
|
||
background: additionalFindingIds.size > 0 ? 'rgba(245,158,11,0.15)' : 'transparent',
|
||
border: `1px solid ${additionalFindingIds.size > 0 ? 'rgba(245,158,11,0.4)' : 'rgba(255,255,255,0.05)'}`,
|
||
borderRadius: '0.375rem',
|
||
color: additionalFindingIds.size > 0 ? '#F59E0B' : '#334155',
|
||
fontSize: '0.75rem', fontWeight: '700', cursor: additionalFindingIds.size > 0 ? 'pointer' : 'not-allowed',
|
||
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
}}>
|
||
{saving ? 'Adding…' : `Add ${additionalFindingIds.size} Finding(s)`}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!isApproved && pendingFpQueue.length === 0 && (
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569' }}>No pending FP queue items available to add.</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Attachments tab */}
|
||
{activeTab === 'attachments' && (
|
||
<div>
|
||
<div style={{ marginBottom: '0.75rem' }}>
|
||
<span style={labelStyle}>Attachments from Initial Submission ({existingAttachments.length})</span>
|
||
{existingAttachments.length === 0 ? (
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569', marginTop: '0.25rem' }}>No attachments were included in the original submission.</div>
|
||
) : (
|
||
<div style={{ marginTop: '0.25rem' }}>
|
||
{existingAttachments.map((att, idx) => (
|
||
<div key={idx} style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||
padding: '0.3rem 0.5rem', marginBottom: '0.15rem',
|
||
borderRadius: '0.25rem',
|
||
background: 'rgba(14,165,233,0.04)',
|
||
border: '1px solid rgba(14,165,233,0.1)',
|
||
}}>
|
||
<FileText style={{ width: '12px', height: '12px', color: '#0EA5E9', flexShrink: 0 }} />
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#CBD5E1', flex: 1 }}>{att.filename}</span>
|
||
<span style={{
|
||
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
|
||
color: att.success ? '#10B981' : '#EF4444',
|
||
}}>
|
||
{att.success ? 'OK' : 'FAILED'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{
|
||
padding: '0.625rem 0.75rem', borderRadius: '0.375rem',
|
||
background: 'rgba(245,158,11,0.06)', border: '1px solid rgba(245,158,11,0.15)',
|
||
fontFamily: 'monospace', fontSize: '0.72rem', color: '#F59E0B',
|
||
}}>
|
||
To add additional attachments, upload them directly in the Ivanti platform on the workflow detail page.
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* History tab */}
|
||
{activeTab === 'history' && (
|
||
<div>
|
||
{/* Ivanti reviewer notes (rework/approval/previous state feedback) */}
|
||
{(() => {
|
||
const notes = [
|
||
submission.ivanti_rework_note && { label: 'Rework Note', text: submission.ivanti_rework_note },
|
||
submission.ivanti_approval_note && { label: 'Approval Note', text: submission.ivanti_approval_note },
|
||
submission.ivanti_current_state_notes && { label: 'Current State Notes', text: submission.ivanti_current_state_notes },
|
||
submission.ivanti_previous_state_notes && { label: 'Previous State Notes', text: submission.ivanti_previous_state_notes },
|
||
].filter(Boolean);
|
||
return notes.length > 0 ? notes.map((note, idx) => (
|
||
<div key={idx} style={{
|
||
padding: '0.625rem 0.75rem', marginBottom: '0.5rem',
|
||
borderRadius: '0.375rem',
|
||
background: 'rgba(245,158,11,0.06)', border: '1px solid rgba(245,158,11,0.2)',
|
||
}}>
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '0.35rem' }}>
|
||
{note.label}
|
||
</div>
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#CBD5E1', whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>
|
||
{note.text}
|
||
</div>
|
||
</div>
|
||
)) : null;
|
||
})()}
|
||
{history.length === 0 ? (
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569', textAlign: 'center', padding: '2rem 0' }}>
|
||
No history entries.
|
||
</div>
|
||
) : history.map((entry, idx) => {
|
||
const details = (() => {
|
||
try { return JSON.parse(entry.change_details_json || '{}'); } catch { return {}; }
|
||
})();
|
||
return (
|
||
<div key={entry.id || idx} style={{
|
||
padding: '0.5rem 0.625rem', marginBottom: '0.35rem',
|
||
borderRadius: '0.25rem',
|
||
background: 'rgba(14,165,233,0.04)',
|
||
border: '1px solid rgba(14,165,233,0.08)',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<span style={{
|
||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||
color: '#0EA5E9', textTransform: 'uppercase',
|
||
}}>
|
||
{(entry.change_type || '').replace(/_/g, ' ')}
|
||
</span>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#475569' }}>
|
||
{entry.created_at ? new Date(entry.created_at).toLocaleString() : ''}
|
||
</span>
|
||
</div>
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B', marginTop: '0.2rem' }}>
|
||
by {entry.username || 'unknown'}
|
||
</div>
|
||
{entry.change_type === 'status_changed' && details.from && (
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94A3B8', marginTop: '0.15rem' }}>
|
||
{details.from} → {details.to}
|
||
</div>
|
||
)}
|
||
{entry.change_type === 'findings_added' && details.addedFindingIds && (
|
||
<div style={{ marginTop: '0.15rem' }}>
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94A3B8' }}>
|
||
+{details.addedFindingIds.length} finding(s):
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem', marginTop: '0.2rem' }}>
|
||
{details.addedFindingIds.map(fid => (
|
||
<span key={fid} style={{
|
||
padding: '0.05rem 0.3rem', borderRadius: '0.15rem',
|
||
background: 'rgba(14,165,233,0.08)', border: '1px solid rgba(14,165,233,0.2)',
|
||
fontFamily: 'monospace', fontSize: '0.6rem', color: '#0EA5E9',
|
||
}}>
|
||
{fid}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{entry.change_type === 'attachments_added' && details.files && (
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94A3B8', marginTop: '0.15rem' }}>
|
||
{details.files.filter(f => f.success).length} of {details.files.length} file(s) uploaded
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SelectionToolbar — batch action bar for multi-selected findings
|
||
// ---------------------------------------------------------------------------
|
||
function SelectionToolbar({ count, workflowType, vendor, submitting, error, onWorkflowChange, onVendorChange, onSubmit, onClear }) {
|
||
const isCard = workflowType === 'CARD';
|
||
const canSubmit = !submitting && (isCard || vendor.trim().length > 0);
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'sticky', top: 0, zIndex: 20,
|
||
display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap',
|
||
padding: '0.625rem 1rem',
|
||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||
border: '1px solid rgba(14,165,233,0.25)',
|
||
borderRadius: '0.375rem',
|
||
marginBottom: '0.5rem',
|
||
}}>
|
||
{/* Count badge */}
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '700', color: '#E2E8F0',
|
||
}}>
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||
minWidth: '22px', height: '22px', padding: '0 6px',
|
||
background: 'rgba(14,165,233,0.2)', border: '1px solid rgba(14,165,233,0.4)',
|
||
borderRadius: '999px', fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '700', color: '#0EA5E9',
|
||
}}>
|
||
{count}
|
||
</span>
|
||
selected
|
||
</span>
|
||
|
||
{/* Workflow type toggles */}
|
||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||
{[
|
||
{ type: 'FP', color: '#F59E0B', rgb: '245,158,11' },
|
||
{ type: 'Archer', color: '#0EA5E9', rgb: '14,165,233' },
|
||
{ type: 'CARD', color: '#10B981', rgb: '16,185,129' },
|
||
].map(({ type, color, rgb }) => {
|
||
const active = workflowType === type;
|
||
return (
|
||
<button
|
||
key={type}
|
||
onClick={() => onWorkflowChange(type)}
|
||
style={{
|
||
padding: '0.25rem 0.5rem',
|
||
background: active ? `rgba(${rgb},0.2)` : 'transparent',
|
||
border: `1px solid rgba(${rgb},${active ? '0.5' : '0.15'})`,
|
||
borderRadius: '0.25rem',
|
||
color: active ? color : '#475569',
|
||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700',
|
||
cursor: 'pointer', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
transition: 'all 0.12s',
|
||
}}
|
||
>
|
||
{type}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Vendor input or CARD indicator */}
|
||
{isCard ? (
|
||
<span style={{
|
||
fontFamily: 'monospace', fontSize: '0.68rem', color: '#10B981',
|
||
padding: '0.25rem 0.5rem',
|
||
background: 'rgba(16,185,129,0.06)', border: '1px solid rgba(16,185,129,0.2)',
|
||
borderRadius: '0.25rem',
|
||
}}>
|
||
No vendor required
|
||
</span>
|
||
) : (
|
||
<input
|
||
type="text"
|
||
value={vendor}
|
||
onChange={(e) => onVendorChange(e.target.value)}
|
||
placeholder="Vendor / Platform"
|
||
style={{
|
||
width: '160px', boxSizing: 'border-box',
|
||
background: 'rgba(14,165,233,0.05)', border: '1px solid rgba(14,165,233,0.2)',
|
||
borderRadius: '0.25rem', padding: '0.3rem 0.5rem',
|
||
color: '#CBD5E1', fontSize: '0.75rem', fontFamily: 'monospace', outline: 'none',
|
||
}}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' && canSubmit) onSubmit(); }}
|
||
/>
|
||
)}
|
||
|
||
{/* Add to Queue button */}
|
||
<button
|
||
onClick={onSubmit}
|
||
disabled={!canSubmit}
|
||
style={{
|
||
padding: '0.3rem 0.75rem',
|
||
background: canSubmit ? 'rgba(14,165,233,0.15)' : 'transparent',
|
||
border: `1px solid rgba(14,165,233,${canSubmit ? '0.4' : '0.1'})`,
|
||
borderRadius: '0.25rem',
|
||
color: canSubmit ? '#0EA5E9' : '#334155',
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
transition: 'all 0.12s',
|
||
}}
|
||
>
|
||
{submitting ? 'Adding…' : 'Add to Queue'}
|
||
</button>
|
||
|
||
{/* Clear selection */}
|
||
<button
|
||
onClick={onClear}
|
||
style={{
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
color: '#475569', padding: '4px', lineHeight: 1,
|
||
}}
|
||
title="Clear selection"
|
||
>
|
||
<X style={{ width: '16px', height: '16px' }} />
|
||
</button>
|
||
|
||
{/* Error message */}
|
||
{error && (
|
||
<span style={{
|
||
fontFamily: 'monospace', fontSize: '0.68rem', color: '#EF4444',
|
||
display: 'flex', alignItems: 'center', gap: '0.25rem',
|
||
}}>
|
||
<AlertCircle style={{ width: '12px', height: '12px' }} />
|
||
{error}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main ReportingPage
|
||
// ---------------------------------------------------------------------------
|
||
export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||
const { canWrite } = useAuth();
|
||
const [findings, setFindings] = useState([]);
|
||
const [total, setTotal] = useState(null);
|
||
const [syncedAt, setSyncedAt] = useState(null);
|
||
const [syncStatus, setSyncStatus] = useState(null);
|
||
const [syncError, setSyncError] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [syncing, setSyncing] = useState(false);
|
||
const [statusCounts, setStatusCounts] = useState({ open: 0, closed: 0 });
|
||
const [countsLoading, setCountsLoading] = useState(true);
|
||
const [fpCounts, setFPCounts] = useState({ findingCounts: {}, findingTotal: 0, idCounts: {}, idTotal: 0 });
|
||
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
||
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
|
||
const [columnFilters, setColumnFilters] = useState(() =>
|
||
filterDate ? { dueDate: new Set([filterDate]) } : {}
|
||
);
|
||
const [openFilter, setOpenFilter] = useState(null);
|
||
const filterBtnRefs = useRef({});
|
||
const [actionFilter, setActionFilter] = useState(null);
|
||
const [excFilter, setExcFilter] = useState(filterEXC || null);
|
||
|
||
const [selectedIds, setSelectedIds] = useState(new Set());
|
||
const [lastClickedId, setLastClickedId] = useState(null);
|
||
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||
const [batchError, setBatchError] = useState(null);
|
||
const [batchWorkflowType, setBatchWorkflowType] = useState('FP');
|
||
const [batchVendor, setBatchVendor] = useState('');
|
||
|
||
// CVE tooltip state & refs
|
||
const [tooltipCveId, setTooltipCveId] = useState(null);
|
||
const [tooltipAnchorRect, setTooltipAnchorRect] = useState(null);
|
||
const tooltipCacheRef = useRef(new Map());
|
||
const hoverTimerRef = useRef(null);
|
||
|
||
const updateColumns = useCallback((newOrder) => {
|
||
setColumnOrder(newOrder);
|
||
saveColumnOrder(newOrder);
|
||
}, []);
|
||
|
||
// CVE tooltip hover handlers
|
||
const handleCveMouseEnter = useCallback((cveId, e) => {
|
||
clearTimeout(hoverTimerRef.current);
|
||
hoverTimerRef.current = setTimeout(() => {
|
||
setTooltipCveId(cveId);
|
||
setTooltipAnchorRect(e.target.getBoundingClientRect());
|
||
}, 300);
|
||
}, []);
|
||
|
||
const handleCveMouseLeave = useCallback(() => {
|
||
clearTimeout(hoverTimerRef.current);
|
||
setTooltipCveId(null);
|
||
setTooltipAnchorRect(null);
|
||
}, []);
|
||
|
||
const applyState = (data) => {
|
||
setTotal(data.total ?? 0);
|
||
setFindings(data.findings || []);
|
||
setSyncedAt(data.synced_at || null);
|
||
setSyncStatus(data.sync_status || null);
|
||
setSyncError(data.error_message || null);
|
||
};
|
||
|
||
const fetchCounts = async () => {
|
||
setCountsLoading(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/findings/counts`, { credentials: 'include' });
|
||
const data = await res.json();
|
||
if (res.ok) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 });
|
||
} catch (e) {
|
||
console.error('Error loading status counts:', e);
|
||
} finally {
|
||
setCountsLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchFPWorkflowCounts = async () => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/findings/fp-workflow-counts`, { credentials: 'include' });
|
||
const data = await res.json();
|
||
if (res.ok) setFPCounts({
|
||
findingCounts: data.findingCounts || {},
|
||
findingTotal: data.findingTotal || 0,
|
||
idCounts: data.idCounts || {},
|
||
idTotal: data.idTotal || 0,
|
||
});
|
||
} catch (e) {
|
||
console.error('Error loading FP workflow counts:', e);
|
||
}
|
||
};
|
||
|
||
const fetchFindings = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
applyState(data);
|
||
tooltipCacheRef.current.clear();
|
||
}
|
||
} catch (e) {
|
||
console.error('Error loading findings:', e);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const syncFindings = async () => {
|
||
setSyncing(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' });
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
applyState(data);
|
||
tooltipCacheRef.current.clear();
|
||
fetchCounts(); // refresh counts after sync
|
||
fetchFPWorkflowCounts(); // refresh FP workflow counts after sync
|
||
}
|
||
} catch (e) {
|
||
console.error('Error syncing findings:', e);
|
||
} finally {
|
||
setSyncing(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchFindings();
|
||
fetchCounts();
|
||
fetchFPWorkflowCounts();
|
||
fetchQueue();
|
||
fetchFpSubmissions();
|
||
}, []); // eslint-disable-line
|
||
|
||
// Set/clear a single column filter
|
||
const setColFilter = useCallback((colKey, vals) => {
|
||
setColumnFilters((prev) => {
|
||
if (!vals) {
|
||
const next = { ...prev };
|
||
delete next[colKey];
|
||
return next;
|
||
}
|
||
return { ...prev, [colKey]: vals };
|
||
});
|
||
}, []);
|
||
|
||
// 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) {
|
||
result = result.filter((f) =>
|
||
active.every(([key, vals]) => {
|
||
if (!vals || vals.size === 0) return false;
|
||
const def = COLUMN_DEFS[key];
|
||
if (def?.multiValue) {
|
||
const arr = f[key] || [];
|
||
if (arr.length === 0) return vals.has(EMPTY_SENTINEL);
|
||
return arr.some((v) => vals.has(String(v).trim()));
|
||
}
|
||
const fval = getFilterVal(f, key).trim();
|
||
return fval === '' ? vals.has(EMPTY_SENTINEL) : vals.has(fval);
|
||
})
|
||
);
|
||
}
|
||
|
||
// 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]);
|
||
|
||
// Sort filtered results
|
||
const sorted = useMemo(() => [...filtered].sort((a, b) => {
|
||
const av = getVal(a, sort.field);
|
||
const bv = getVal(b, sort.field);
|
||
let cmp = 0;
|
||
if (typeof av === 'number' && typeof bv === 'number') {
|
||
cmp = av - bv;
|
||
} else {
|
||
cmp = String(av).localeCompare(String(bv), undefined, { numeric: true });
|
||
}
|
||
return sort.dir === 'asc' ? cmp : -cmp;
|
||
}), [filtered, sort]);
|
||
|
||
const toggleSort = (key) => {
|
||
setSort((prev) =>
|
||
prev.field === key
|
||
? { field: key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
|
||
: { field: key, dir: 'asc' }
|
||
);
|
||
};
|
||
|
||
const activeFilterCount = Object.keys(columnFilters).length + (actionFilter ? 1 : 0) + (excFilter ? 1 : 0);
|
||
|
||
// Queue state
|
||
const [queueItems, setQueueItems] = useState([]);
|
||
const [queueOpen, setQueueOpen] = useState(false);
|
||
const [queueLoading, setQueueLoading] = useState(false);
|
||
const [addPopover, setAddPopover] = useState(null); // { finding, anchorRect }
|
||
const [queueForm, setQueueForm] = useState({ vendor: '', workflowType: 'FP' });
|
||
|
||
// FP Workflow modal state
|
||
const [fpModalOpen, setFpModalOpen] = useState(false);
|
||
const [fpModalItems, setFpModalItems] = useState([]);
|
||
|
||
// FP Submission editing state
|
||
const [fpSubmissionsRaw, setFpSubmissions] = useState([]);
|
||
const [editSubmission, setEditSubmission] = useState(null);
|
||
|
||
// Enrich submissions with actual Ivanti workflow state from findings data
|
||
const fpSubmissions = useMemo(() => {
|
||
if (fpSubmissionsRaw.length === 0 || findings.length === 0) return fpSubmissionsRaw;
|
||
const stateMap = {
|
||
'reworked': 'rework', 'rejected': 'rejected', 'expired': 'rejected',
|
||
'approved': 'approved', 'requested': 'submitted', 'actionable': 'submitted',
|
||
};
|
||
return fpSubmissionsRaw.map(sub => {
|
||
let findingIds;
|
||
try { findingIds = JSON.parse(sub.finding_ids_json || '[]'); } catch { return sub; }
|
||
if (findingIds.length === 0) return sub;
|
||
const matchedFinding = findings.find(f =>
|
||
f.workflow && findingIds.includes(String(f.id))
|
||
);
|
||
if (!matchedFinding || !matchedFinding.workflow) return sub;
|
||
const ivantiState = (matchedFinding.workflow.state || '').toLowerCase();
|
||
const mappedStatus = stateMap[ivantiState];
|
||
if (mappedStatus && mappedStatus !== sub.lifecycle_status) {
|
||
return { ...sub, lifecycle_status: mappedStatus };
|
||
}
|
||
return sub;
|
||
});
|
||
}, [fpSubmissionsRaw, findings]);
|
||
|
||
// Queue API helpers
|
||
const fetchQueue = useCallback(async () => {
|
||
setQueueLoading(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/todo-queue`, { credentials: 'include' });
|
||
const data = await res.json();
|
||
if (res.ok) setQueueItems(data);
|
||
} catch (e) {
|
||
console.error('Error fetching queue:', e);
|
||
} finally {
|
||
setQueueLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const fetchFpSubmissions = useCallback(async () => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions`, { credentials: 'include' });
|
||
const data = await res.json();
|
||
if (res.ok) setFpSubmissions(data);
|
||
} catch (e) {
|
||
console.error('Error fetching FP submissions:', e);
|
||
}
|
||
}, []);
|
||
|
||
// FP Workflow handlers
|
||
const handleCreateFpWorkflow = useCallback((selectedIds) => {
|
||
const selectedSet = new Set(selectedIds);
|
||
const fpItems = filterFpItems(
|
||
queueItems.filter(item => selectedSet.has(item.id) && item.status === 'pending')
|
||
);
|
||
if (fpItems.length > 0) {
|
||
setFpModalItems(fpItems);
|
||
setFpModalOpen(true);
|
||
}
|
||
}, [queueItems]);
|
||
|
||
const handleFpWorkflowSuccess = useCallback(() => {
|
||
fetchQueue();
|
||
}, [fetchQueue]);
|
||
|
||
const handleEditSubmission = useCallback((submission) => {
|
||
setEditSubmission(submission);
|
||
}, []);
|
||
|
||
const handleEditSuccess = useCallback(() => {
|
||
fetchFpSubmissions();
|
||
fetchQueue();
|
||
fetchFindings();
|
||
}, [fetchFpSubmissions, fetchQueue]); // eslint-disable-line
|
||
|
||
const addToQueue = useCallback(async () => {
|
||
if (!addPopover) return;
|
||
const { finding } = addPopover;
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/todo-queue`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
finding_id: finding.id,
|
||
finding_title: finding.title || null,
|
||
cves: finding.cves || [],
|
||
ip_address: finding.ipAddress || null,
|
||
hostname: finding.overrides?.hostName || finding.hostName || null,
|
||
vendor: queueForm.vendor.trim(),
|
||
workflow_type: queueForm.workflowType,
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
setQueueItems((prev) => [...prev, data].sort((a, b) =>
|
||
a.vendor.localeCompare(b.vendor) || a.id - b.id
|
||
));
|
||
}
|
||
} catch (e) {
|
||
console.error('Error adding to queue:', e);
|
||
}
|
||
setAddPopover(null);
|
||
setQueueForm({ vendor: '', workflowType: 'FP' });
|
||
}, [addPopover, queueForm]);
|
||
|
||
// Prune selection when filters change — keep only IDs still in filtered set
|
||
useEffect(() => {
|
||
setSelectedIds((prev) => {
|
||
if (prev.size === 0) return prev;
|
||
const visibleIds = new Set(filtered.map((f) => f.id));
|
||
const next = new Set([...prev].filter((id) => visibleIds.has(id)));
|
||
return next.size === prev.size ? prev : next;
|
||
});
|
||
}, [filtered]);
|
||
|
||
// Escape key clears selection
|
||
useEffect(() => {
|
||
if (selectedIds.size === 0) return;
|
||
const handler = (e) => {
|
||
if (e.key === 'Escape' && selectedIds.size > 0 && !addPopover) {
|
||
setSelectedIds(new Set());
|
||
setBatchError(null);
|
||
}
|
||
};
|
||
document.addEventListener('keydown', handler);
|
||
return () => document.removeEventListener('keydown', handler);
|
||
}, [selectedIds, addPopover]);
|
||
|
||
const submitBatch = useCallback(async () => {
|
||
if (selectedIds.size === 0) return;
|
||
setBatchSubmitting(true);
|
||
setBatchError(null);
|
||
try {
|
||
const findingsPayload = [...selectedIds].map((id) => {
|
||
const f = findings.find((ff) => ff.id === id);
|
||
return f ? {
|
||
finding_id: f.id,
|
||
finding_title: f.title || null,
|
||
cves: f.cves || [],
|
||
ip_address: f.ipAddress || null,
|
||
hostname: f.overrides?.hostName || f.hostName || null,
|
||
} : { finding_id: id };
|
||
});
|
||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/batch`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
findings: findingsPayload,
|
||
workflow_type: batchWorkflowType,
|
||
vendor: batchWorkflowType === 'CARD' ? '' : batchVendor.trim(),
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
setQueueItems((prev) => [...prev, ...(data.items || [])].sort((a, b) =>
|
||
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
|
||
));
|
||
setSelectedIds(new Set());
|
||
setBatchWorkflowType('FP');
|
||
setBatchVendor('');
|
||
setBatchError(null);
|
||
} else {
|
||
setBatchError(data.error || 'Failed to add findings to queue.');
|
||
}
|
||
} catch (e) {
|
||
console.error('Error in batch add:', e);
|
||
setBatchError('Network error — please try again.');
|
||
} finally {
|
||
setBatchSubmitting(false);
|
||
}
|
||
}, [selectedIds, findings, batchWorkflowType, batchVendor]);
|
||
|
||
const updateQueueItem = useCallback(async (id, changes) => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, {
|
||
method: 'PUT',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(changes),
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
setQueueItems((prev) => prev.map((item) => item.id === id ? data : item));
|
||
}
|
||
} catch (e) {
|
||
console.error('Error updating queue item:', e);
|
||
}
|
||
}, []);
|
||
|
||
const deleteQueueItem = useCallback(async (id) => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, {
|
||
method: 'DELETE',
|
||
credentials: 'include',
|
||
});
|
||
if (res.ok) setQueueItems((prev) => prev.filter((item) => item.id !== id));
|
||
} catch (e) {
|
||
console.error('Error deleting queue item:', e);
|
||
}
|
||
}, []);
|
||
|
||
const deleteQueueItems = useCallback(async (ids) => {
|
||
try {
|
||
await Promise.all(ids.map((id) =>
|
||
fetch(`${API_BASE}/ivanti/todo-queue/${id}`, { method: 'DELETE', credentials: 'include' })
|
||
));
|
||
const removed = new Set(ids);
|
||
setQueueItems((prev) => prev.filter((item) => !removed.has(item.id)));
|
||
} catch (e) {
|
||
console.error('Error bulk-deleting queue items:', e);
|
||
}
|
||
}, []);
|
||
|
||
const clearCompleted = useCallback(async () => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/completed`, {
|
||
method: 'DELETE',
|
||
credentials: 'include',
|
||
});
|
||
if (res.ok) setQueueItems((prev) => prev.filter((item) => item.status !== 'complete'));
|
||
} catch (e) {
|
||
console.error('Error clearing completed queue items:', e);
|
||
}
|
||
}, []);
|
||
|
||
const isQueued = useCallback((findingId) =>
|
||
queueItems.some((item) => item.finding_id === findingId),
|
||
[queueItems]);
|
||
|
||
const pendingQueueCount = queueItems.filter((i) => i.status === 'pending').length;
|
||
|
||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||
const exportBtnRef = useRef(null);
|
||
|
||
// Close export menu on outside click
|
||
useEffect(() => {
|
||
if (!exportMenuOpen) return;
|
||
const handler = (e) => {
|
||
if (exportBtnRef.current && !exportBtnRef.current.contains(e.target)) {
|
||
setExportMenuOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [exportMenuOpen]);
|
||
|
||
const buildExportRows = useCallback(() => {
|
||
const cols = visibleCols.filter((c) => COLUMN_DEFS[c.key]);
|
||
const headers = cols.map((c) => COLUMN_DEFS[c.key].label);
|
||
const rows = sorted.map((finding) =>
|
||
cols.map((c) => getExportVal(finding, c.key))
|
||
);
|
||
return [headers, ...rows];
|
||
}, [sorted, visibleCols]);
|
||
|
||
const exportCSV = useCallback(() => {
|
||
setExportMenuOpen(false);
|
||
const rows = buildExportRows();
|
||
const csvContent = rows.map((row) =>
|
||
row.map((cell) => {
|
||
const s = String(cell ?? '');
|
||
// Quote if it contains comma, double-quote, or newline
|
||
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
||
return `"${s.replace(/"/g, '""')}"`;
|
||
}
|
||
return s;
|
||
}).join(',')
|
||
).join('\r\n');
|
||
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `findings-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}, [buildExportRows]);
|
||
|
||
const exportXLSX = useCallback(() => {
|
||
setExportMenuOpen(false);
|
||
const rows = buildExportRows();
|
||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||
// Auto-fit column widths
|
||
const colWidths = rows[0].map((_, ci) =>
|
||
Math.min(60, Math.max(10, ...rows.map((r) => String(r[ci] ?? '').length)))
|
||
);
|
||
ws['!cols'] = colWidths.map((w) => ({ wch: w }));
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, 'Findings');
|
||
XLSX.writeFile(wb, `findings-export-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||
}, [buildExportRows]);
|
||
|
||
const syncedDisplay = syncedAt
|
||
? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
||
: 'Never synced';
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Render
|
||
// -------------------------------------------------------------------------
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||
|
||
{/* ----------------------------------------------------------------
|
||
Panel 1 — Metrics placeholder
|
||
---------------------------------------------------------------- */}
|
||
<div style={{
|
||
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
||
border: '1px solid rgba(245,158,11,0.2)',
|
||
borderLeft: '3px solid #F59E0B',
|
||
borderRadius: '0.5rem',
|
||
padding: '1.5rem',
|
||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '1rem' }}>
|
||
<PieChart style={{ width: '20px', height: '20px', color: '#F59E0B' }} />
|
||
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(245,158,11,0.4)', margin: 0 }}>
|
||
Metric Graphs
|
||
</h2>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||
{/* Open vs Closed 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' }}>
|
||
Open vs Closed
|
||
</div>
|
||
<StatusDonut
|
||
open={statusCounts.open}
|
||
closed={statusCounts.closed}
|
||
loading={countsLoading}
|
||
/>
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||
|
||
{/* 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' }}>
|
||
Action Coverage
|
||
{actionFilter && <span style={{ marginLeft: '0.5rem', color: ACTION_DEFS.find(d => d.key === actionFilter)?.color, fontSize: '0.6rem' }}>● filtered</span>}
|
||
</div>
|
||
<ActionCoverageDonut
|
||
findings={findings}
|
||
activeSegment={actionFilter}
|
||
onSegmentClick={(key) => {
|
||
setExcFilter(null);
|
||
setActionFilter(key);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||
|
||
{/* FP Finding Status donut — # of findings per FP workflow state */}
|
||
<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 Finding Status
|
||
</div>
|
||
<FPWorkflowDonut counts={fpCounts.findingCounts} total={fpCounts.findingTotal} centerLabel="FINDINGS" />
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||
|
||
{/* FP Workflow Status donut — # of unique FP# ticket IDs per state */}
|
||
<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
|
||
</div>
|
||
<FPWorkflowDonut counts={fpCounts.idCounts} total={fpCounts.idTotal} centerLabel="FP TICKETS" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ----------------------------------------------------------------
|
||
Panel 1.5 — Open vs Closed trend over time
|
||
---------------------------------------------------------------- */}
|
||
<IvantiCountsChart />
|
||
|
||
{/* ----------------------------------------------------------------
|
||
Panel 2 — Findings table
|
||
---------------------------------------------------------------- */}
|
||
<div style={{
|
||
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
||
border: '1px solid rgba(14,165,233,0.2)',
|
||
borderLeft: '3px solid #0EA5E9',
|
||
borderRadius: '0.5rem',
|
||
padding: '1.5rem',
|
||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
|
||
}}>
|
||
{/* Panel header */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
||
<div>
|
||
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14,165,233,0.4)', margin: '0 0 4px 0' }}>
|
||
Host Findings
|
||
</h2>
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
|
||
{syncedDisplay}
|
||
{syncStatus === 'success' && total !== null && (
|
||
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>
|
||
{activeFilterCount > 0 ? `${filtered.length} of ${total}` : total} findings
|
||
{activeFilterCount > 0 && (
|
||
<span style={{ marginLeft: '0.5rem', color: '#F59E0B' }}>
|
||
({activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active)
|
||
</span>
|
||
)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action buttons */}
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||
{/* 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={{
|
||
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',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em'
|
||
}}
|
||
>
|
||
<Filter style={{ width: '11px', height: '11px' }} />
|
||
Clear Filters
|
||
</button>
|
||
)}
|
||
{/* Export dropdown */}
|
||
<div ref={exportBtnRef} style={{ position: 'relative' }}>
|
||
<button
|
||
onClick={() => setExportMenuOpen((o) => !o)}
|
||
disabled={sorted.length === 0}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.375rem 0.75rem',
|
||
background: 'rgba(16,185,129,0.08)',
|
||
border: '1px solid rgba(16,185,129,0.3)',
|
||
borderRadius: '0.375rem',
|
||
color: '#10B981', cursor: sorted.length === 0 ? 'not-allowed' : 'pointer',
|
||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
opacity: sorted.length === 0 ? 0.4 : 1,
|
||
}}
|
||
>
|
||
<Download style={{ width: '11px', height: '11px' }} />
|
||
Export
|
||
<ChevronDown style={{ width: '10px', height: '10px', marginLeft: '1px' }} />
|
||
</button>
|
||
{exportMenuOpen && (
|
||
<div style={{
|
||
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 200,
|
||
background: 'rgb(12,22,40)', border: '1px solid rgba(16,185,129,0.3)',
|
||
borderRadius: '0.375rem', overflow: 'hidden',
|
||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||
minWidth: '120px',
|
||
}}>
|
||
{[
|
||
{ label: 'CSV (.csv)', action: exportCSV },
|
||
{ label: 'Excel (.xlsx)', action: exportXLSX },
|
||
].map(({ label, action }) => (
|
||
<button
|
||
key={label}
|
||
onClick={action}
|
||
style={{
|
||
display: 'block', width: '100%', textAlign: 'left',
|
||
padding: '0.5rem 0.875rem',
|
||
background: 'none', border: 'none',
|
||
fontFamily: 'monospace', fontSize: '0.73rem', fontWeight: '600',
|
||
color: '#10B981', cursor: 'pointer',
|
||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||
transition: 'background 0.1s',
|
||
}}
|
||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(16,185,129,0.1)'}
|
||
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Queue button */}
|
||
<button
|
||
onClick={() => setQueueOpen((o) => !o)}
|
||
style={{
|
||
position: 'relative',
|
||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.375rem 0.75rem',
|
||
background: queueOpen ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.08)',
|
||
border: `1px solid rgba(14,165,233,${queueOpen ? '0.5' : '0.25'})`,
|
||
borderRadius: '0.375rem',
|
||
color: '#0EA5E9', cursor: 'pointer',
|
||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
}}
|
||
>
|
||
<ListTodo style={{ width: '13px', height: '13px' }} />
|
||
Queue
|
||
{pendingQueueCount > 0 && (
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||
background: '#0EA5E9', borderRadius: '999px',
|
||
fontFamily: 'monospace', fontSize: '0.58rem', fontWeight: '700', color: '#0A1628',
|
||
marginLeft: '1px',
|
||
}}>
|
||
{pendingQueueCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
||
<button
|
||
onClick={syncFindings}
|
||
disabled={syncing || loading}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.375rem 0.75rem',
|
||
background: 'rgba(14,165,233,0.1)',
|
||
border: '1px solid rgba(14,165,233,0.35)',
|
||
borderRadius: '0.375rem',
|
||
color: '#0EA5E9', cursor: 'pointer',
|
||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
opacity: (syncing || loading) ? 0.6 : 1
|
||
}}
|
||
>
|
||
<RefreshCw style={{ width: '13px', height: '13px', animation: syncing ? 'spin 1s linear infinite' : 'none' }} />
|
||
{syncing ? 'Syncing…' : 'Sync'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error banner */}
|
||
{syncStatus === 'error' && syncError && (
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', padding: '0.625rem 0.875rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '0.375rem', marginBottom: '1rem' }}>
|
||
<AlertCircle style={{ width: '15px', height: '15px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
|
||
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>{syncError}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Content */}
|
||
{loading ? (
|
||
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
||
<Loader style={{ width: '28px', height: '28px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto 0.75rem' }} />
|
||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Loading findings…</p>
|
||
</div>
|
||
) : syncStatus === 'never' ? (
|
||
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Click Sync to load findings data</p>
|
||
</div>
|
||
) : (
|
||
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: 'calc(100vh - 420px)', minHeight: '200px', marginTop: '0.75rem' }}>
|
||
{selectedIds.size > 0 && canWrite() && (
|
||
<SelectionToolbar
|
||
count={selectedIds.size}
|
||
workflowType={batchWorkflowType}
|
||
vendor={batchVendor}
|
||
submitting={batchSubmitting}
|
||
error={batchError}
|
||
onWorkflowChange={setBatchWorkflowType}
|
||
onVendorChange={setBatchVendor}
|
||
onSubmit={submitBatch}
|
||
onClear={() => { setSelectedIds(new Set()); setBatchError(null); }}
|
||
/>
|
||
)}
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
|
||
{/* Fixed checkbox column — not part of column manager */}
|
||
<th
|
||
style={{
|
||
width: '36px', minWidth: '36px', padding: '0.5rem 0.5rem',
|
||
background: 'rgb(10, 20, 36)',
|
||
position: 'sticky', top: 0, zIndex: 10,
|
||
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
{canWrite() && (
|
||
<input
|
||
type="checkbox"
|
||
checked={sorted.length > 0 && sorted.filter((f) => !isQueued(f.id)).length > 0 && sorted.filter((f) => !isQueued(f.id)).every((f) => selectedIds.has(f.id))}
|
||
onChange={() => {
|
||
const nonQueued = sorted.filter((f) => !isQueued(f.id));
|
||
const allSelected = nonQueued.length > 0 && nonQueued.every((f) => selectedIds.has(f.id));
|
||
if (allSelected) {
|
||
setSelectedIds(new Set());
|
||
} else {
|
||
setSelectedIds(new Set(nonQueued.map((f) => f.id)));
|
||
}
|
||
}}
|
||
style={{
|
||
accentColor: '#0EA5E9',
|
||
width: '13px', height: '13px',
|
||
cursor: 'pointer',
|
||
}}
|
||
title="Select all visible findings"
|
||
/>
|
||
)}
|
||
</th>
|
||
{visibleCols.map((col) => {
|
||
const def = COLUMN_DEFS[col.key];
|
||
const active = sort.field === col.key;
|
||
const isFiltered = !!columnFilters[col.key];
|
||
return (
|
||
<th
|
||
key={col.key}
|
||
onClick={def?.sortable ? () => toggleSort(col.key) : undefined}
|
||
style={{
|
||
padding: '0.5rem 0.75rem', textAlign: 'left',
|
||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||
color: active ? '#0EA5E9' : '#64748B',
|
||
textTransform: 'uppercase', letterSpacing: '0.08em',
|
||
whiteSpace: 'nowrap',
|
||
cursor: def?.sortable ? 'pointer' : 'default',
|
||
userSelect: 'none',
|
||
background: 'rgb(10, 20, 36)',
|
||
position: 'sticky', top: 0, zIndex: 10,
|
||
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
|
||
}}
|
||
>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||
{def?.label || col.key}
|
||
{def?.sortable && <SortIcon colKey={col.key} sort={sort} />}
|
||
{def?.filterable && (
|
||
<button
|
||
ref={(el) => { filterBtnRefs.current[col.key] = el; }}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setOpenFilter(openFilter === col.key ? null : col.key);
|
||
}}
|
||
title={`Filter ${def.label}`}
|
||
style={{
|
||
background: 'none', border: 'none',
|
||
cursor: 'pointer', padding: '1px 1px 1px 3px',
|
||
color: isFiltered ? '#F59E0B' : '#334155',
|
||
lineHeight: 1, flexShrink: 0,
|
||
transition: 'color 0.15s',
|
||
}}
|
||
>
|
||
<Filter style={{ width: '10px', height: '10px' }} />
|
||
</button>
|
||
)}
|
||
</span>
|
||
</th>
|
||
);
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sorted.map((finding, idx) => {
|
||
const isSelected = selectedIds.has(finding.id);
|
||
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)');
|
||
const queued = isQueued(finding.id);
|
||
return (
|
||
<tr
|
||
key={finding.id}
|
||
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', background: rowBg }}
|
||
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
|
||
>
|
||
{/* Checkbox cell */}
|
||
<td
|
||
style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}
|
||
onClick={(e) => {
|
||
if (queued) return;
|
||
// Shift-click range select
|
||
if (e.shiftKey && lastClickedId) {
|
||
const lastIdx = sorted.findIndex((f) => f.id === lastClickedId);
|
||
const currIdx = sorted.findIndex((f) => f.id === finding.id);
|
||
if (lastIdx !== -1 && currIdx !== -1) {
|
||
const start = Math.min(lastIdx, currIdx);
|
||
const end = Math.max(lastIdx, currIdx);
|
||
setSelectedIds((prev) => {
|
||
const next = new Set(prev);
|
||
for (let i = start; i <= end; i++) {
|
||
if (!isQueued(sorted[i].id)) next.add(sorted[i].id);
|
||
}
|
||
return next;
|
||
});
|
||
}
|
||
} else {
|
||
// Regular click — toggle selection
|
||
setSelectedIds((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id);
|
||
return next;
|
||
});
|
||
}
|
||
setLastClickedId(finding.id);
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
readOnly
|
||
checked={queued || isSelected}
|
||
style={{
|
||
accentColor: queued ? '#10B981' : '#0EA5E9',
|
||
width: '13px', height: '13px',
|
||
cursor: queued ? 'default' : 'pointer',
|
||
pointerEvents: 'none',
|
||
}}
|
||
/>
|
||
</td>
|
||
{visibleCols.map((col) => (
|
||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} />
|
||
))}
|
||
</tr>
|
||
);
|
||
})}
|
||
{sorted.length === 0 && (
|
||
<tr>
|
||
<td colSpan={visibleCols.length + 1} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Filter dropdown — rendered via portal at document.body */}
|
||
{openFilter && COLUMN_DEFS[openFilter]?.filterable && (
|
||
<FilterDropdown
|
||
anchorEl={filterBtnRefs.current[openFilter]}
|
||
colKey={openFilter}
|
||
findings={findings}
|
||
activeFilter={columnFilters[openFilter] || null}
|
||
onFilterChange={(vals) => setColFilter(openFilter, vals)}
|
||
onClose={() => setOpenFilter(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* Add-to-queue popover — portal */}
|
||
{addPopover && (
|
||
<AddToQueuePopover
|
||
finding={addPopover.finding}
|
||
anchorRect={addPopover.anchorRect}
|
||
queueForm={queueForm}
|
||
setQueueForm={setQueueForm}
|
||
onAdd={addToQueue}
|
||
onCancel={() => {
|
||
setAddPopover(null);
|
||
setQueueForm({ vendor: '', workflowType: 'FP' });
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Queue panel — fixed slide-out */}
|
||
<QueuePanel
|
||
open={queueOpen}
|
||
items={queueItems}
|
||
onClose={() => setQueueOpen(false)}
|
||
onUpdate={updateQueueItem}
|
||
onDelete={deleteQueueItem}
|
||
onDeleteMany={deleteQueueItems}
|
||
onClearCompleted={clearCompleted}
|
||
onCreateFpWorkflow={handleCreateFpWorkflow}
|
||
onRedirectComplete={(newItem) => {
|
||
setQueueItems((prev) => [...prev, newItem].sort((a, b) =>
|
||
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
|
||
));
|
||
}}
|
||
canWrite={canWrite}
|
||
fpSubmissions={fpSubmissions}
|
||
onEditSubmission={handleEditSubmission}
|
||
/>
|
||
<FpWorkflowModal
|
||
open={fpModalOpen}
|
||
onClose={() => setFpModalOpen(false)}
|
||
selectedItems={fpModalItems}
|
||
onSuccess={handleFpWorkflowSuccess}
|
||
/>
|
||
<FpEditModal
|
||
open={!!editSubmission}
|
||
onClose={() => setEditSubmission(null)}
|
||
submission={editSubmission}
|
||
queueItems={queueItems}
|
||
onSuccess={handleEditSuccess}
|
||
/>
|
||
<CveTooltip
|
||
cveId={tooltipCveId}
|
||
anchorRect={tooltipAnchorRect}
|
||
cache={tooltipCacheRef}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|