CARD workflow type no longer requires a vendor/platform entry since asset disposition is handled entirely within CARD. In the popover the vendor field is replaced with a note when CARD is selected, and the Add button is enabled immediately. In the queue panel, CARD items are separated into their own top section (green header) rather than being mixed into vendor groups. Backend validation updated to skip vendor requirement for CARD. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2256 lines
113 KiB
JavaScript
2256 lines
113 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 } from 'lucide-react';
|
||
import * as XLSX from 'xlsx';
|
||
import { useAuth } from '../../contexts/AuthContext';
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 }) {
|
||
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} 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, onClearCompleted }) {
|
||
const pendingCount = items.filter((i) => i.status === 'pending').length;
|
||
const completedCount = items.filter((i) => i.status === 'complete').length;
|
||
|
||
// 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}` : '')
|
||
: '—';
|
||
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',
|
||
}}
|
||
>
|
||
{/* 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>
|
||
{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>
|
||
)}
|
||
</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>
|
||
|
||
{/* 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>
|
||
|
||
{/* Footer */}
|
||
<div style={{
|
||
padding: '0.75rem 1.25rem',
|
||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||
flexShrink: 0,
|
||
}}>
|
||
<button
|
||
onClick={onClearCompleted}
|
||
disabled={completedCount === 0}
|
||
style={{
|
||
width: '100%', 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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main ReportingPage
|
||
// ---------------------------------------------------------------------------
|
||
export default function ReportingPage({ 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 updateColumns = useCallback((newOrder) => {
|
||
setColumnOrder(newOrder);
|
||
saveColumnOrder(newOrder);
|
||
}, []);
|
||
|
||
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);
|
||
} 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);
|
||
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();
|
||
}, []); // 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' });
|
||
|
||
// 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 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 || [],
|
||
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]);
|
||
|
||
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 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 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' }}>
|
||
<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)',
|
||
}}
|
||
/>
|
||
{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 rowBg = 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) => 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;
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
setAddPopover({ finding, anchorRect: rect });
|
||
setQueueForm({ vendor: '', workflowType: 'FP' });
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
readOnly
|
||
checked={queued}
|
||
disabled={queued}
|
||
style={{
|
||
accentColor: '#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()} />
|
||
))}
|
||
</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}
|
||
onClearCompleted={clearCompleted}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|