Problem 1: Atlas sync was querying ALL host_ids from ivanti_findings regardless of BU, writing 'no plan' entries for ACCESS-OPS hosts that Atlas doesn't cover. Now the sync respects the user's active teams scope (passed via query param) and falls back to IVANTI_MANAGED_BUS when no scope is provided. Problem 2: Atlas /metrics and /status endpoints returned unscoped data from the full cache, so changing scope didn't update the Atlas Coverage donut or badge counts. Both endpoints now accept a teams query param and JOIN against ivanti_findings to scope results by BU. Frontend changes: - fetchAtlasStatus and fetchAtlasMetrics now pass teams param - Atlas sync button passes active teams to the sync endpoint - Scope change (adminScope) triggers Atlas data refresh Also purged 6,461 polluted cache entries for non-managed BU hosts.
7816 lines
424 KiB
JavaScript
7816 lines
424 KiB
JavaScript
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||
import ReactDOM from 'react-dom';
|
||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet, Layers } from 'lucide-react';
|
||
import * as XLSX from 'xlsx';
|
||
import { useAuth } from '../../contexts/AuthContext';
|
||
import IvantiCountsChart from './IvantiCountsChart';
|
||
import AnomalyBanner from './AnomalyBanner';
|
||
import CveTooltip from '../CveTooltip';
|
||
import CardOwnerTooltip from '../CardOwnerTooltip';
|
||
import CardDetailModal from '../CardDetailModal';
|
||
import RedirectModal from '../RedirectModal';
|
||
import RemediationModal from '../RemediationModal';
|
||
import AtlasBadge from '../AtlasBadge';
|
||
import LoaderModal from '../LoaderModal';
|
||
import CardActionModal from '../CardActionModal';
|
||
import ConsolidationModal from '../ConsolidationModal';
|
||
import AtlasSlideOutPanel from '../AtlasSlideOutPanel';
|
||
import AtlasIcon from '../AtlasIcon';
|
||
|
||
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 */ }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Persist / load hidden row IDs (row visibility feature)
|
||
// ---------------------------------------------------------------------------
|
||
const HIDDEN_ROWS_KEY = 'steam_findings_hidden_rows';
|
||
|
||
function loadHiddenRows() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(HIDDEN_ROWS_KEY) || 'null');
|
||
if (saved && Array.isArray(saved)) return new Set(saved);
|
||
} catch { /* corrupted — treat as empty */ }
|
||
return new Set();
|
||
}
|
||
|
||
function saveHiddenRows(hiddenSet) {
|
||
try { localStorage.setItem(HIDDEN_ROWS_KEY, JSON.stringify([...hiddenSet])); } 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.overrides?.hostName || finding.hostName || '';
|
||
case 'ipAddress': return finding.ipAddress ?? '';
|
||
case 'dns': return finding.overrides?.dns || 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.overrides?.hostName || finding.hostName || '';
|
||
case 'ipAddress': return finding.ipAddress ?? '';
|
||
case 'dns': return finding.overrides?.dns || 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>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Atlas Donut Charts — Coverage, Plan Type, Plan Status
|
||
// ---------------------------------------------------------------------------
|
||
const PLAN_TYPE_DEFS = [
|
||
{ key: 'decommission', label: 'Decommission', color: '#EF4444' },
|
||
{ key: 'remediation', label: 'Remediation', color: '#0EA5E9' },
|
||
{ key: 'false_positive', label: 'False Positive', color: '#A855F7' },
|
||
{ key: 'risk_acceptance', label: 'Risk Acceptance', color: '#F59E0B' },
|
||
{ key: 'scan_exclusion', label: 'Scan Exclusion', color: '#64748B' },
|
||
];
|
||
|
||
function getStatusColor(status) {
|
||
if (status === 'active') return '#10B981';
|
||
if (status === 'expired') return '#EF4444';
|
||
if (status === 'completed') return '#0EA5E9';
|
||
return '#64748B';
|
||
}
|
||
|
||
function AtlasCoverageDonut({ hostsWithPlans, hostsWithoutPlans, totalHosts }) {
|
||
const SIZE = 180;
|
||
const CX = SIZE / 2;
|
||
const CY = SIZE / 2;
|
||
const OUTER = 72;
|
||
const INNER = 48;
|
||
|
||
if (totalHosts === 0) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data — run Atlas Sync</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const segments = [
|
||
{ label: 'With Plans', count: hostsWithPlans, color: '#10B981', start: 0, end: (hostsWithPlans / totalHosts) * 360 },
|
||
{ label: 'Without Plans', count: hostsWithoutPlans, color: '#F59E0B', start: (hostsWithPlans / totalHosts) * 360, end: 360 },
|
||
].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.label}
|
||
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' }}>
|
||
{totalHosts.toLocaleString()}
|
||
</text>
|
||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||
HOSTS
|
||
</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 / totalHosts) * 100).toFixed(1)}%)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AtlasPlanTypeDonut({ plansByType, totalPlans }) {
|
||
const SIZE = 180;
|
||
const CX = SIZE / 2;
|
||
const CY = SIZE / 2;
|
||
const OUTER = 72;
|
||
const INNER = 48;
|
||
|
||
if (totalPlans === 0) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No plans — run Atlas Sync</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
let cursor = 0;
|
||
const segments = PLAN_TYPE_DEFS.map((def) => {
|
||
const count = plansByType[def.key] || 0;
|
||
const start = cursor;
|
||
const end = count > 0 ? cursor + (count / totalPlans) * 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' }}>
|
||
{totalPlans.toLocaleString()}
|
||
</text>
|
||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||
PLANS
|
||
</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 / totalPlans) * 100).toFixed(0)}%)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AtlasPlanStatusDonut({ plansByStatus, totalPlans }) {
|
||
const SIZE = 180;
|
||
const CX = SIZE / 2;
|
||
const CY = SIZE / 2;
|
||
const OUTER = 72;
|
||
const INNER = 48;
|
||
|
||
if (totalPlans === 0) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No plans — run Atlas Sync</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const entries = Object.entries(plansByStatus).filter(([, count]) => count > 0);
|
||
|
||
let cursor = 0;
|
||
const segments = entries.map(([status, count]) => {
|
||
const start = cursor;
|
||
const end = cursor + (count / totalPlans) * 360;
|
||
cursor = end;
|
||
return {
|
||
key: status,
|
||
label: status.charAt(0).toUpperCase() + status.slice(1),
|
||
color: getStatusColor(status),
|
||
count,
|
||
start,
|
||
end,
|
||
};
|
||
});
|
||
|
||
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' }}>
|
||
{totalPlans.toLocaleString()}
|
||
</text>
|
||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||
STATUS
|
||
</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 / totalPlans) * 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, suffix }) {
|
||
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>
|
||
{suffix}
|
||
</td>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// NoteCell — inline editable, saves on blur
|
||
// ---------------------------------------------------------------------------
|
||
function NoteCell({ findingId, initialNote, onNoteSaved }) {
|
||
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;
|
||
if (onNoteSaved) onNoteSaved(findingId, value);
|
||
} catch (e) {
|
||
console.error('Failed to save note:', e);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}, [findingId, value, onNoteSaved]);
|
||
|
||
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, onIpMouseEnter, onIpMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick, onNoteSaved }) {
|
||
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}
|
||
suffix={
|
||
<AtlasBadge
|
||
hostId={finding.hostId}
|
||
atlasStatus={atlasStatusMap ? atlasStatusMap.get(finding.hostId) : undefined}
|
||
onClick={onAtlasBadgeClick}
|
||
/>
|
||
}
|
||
/>
|
||
);
|
||
case 'ipAddress': {
|
||
// Display priority: IPv4 > Qualys IPv6 > Primary IPv6
|
||
const displayIp = finding.ipAddress || finding.qualysIpv6 || finding.primaryIpv6 || '';
|
||
const ipSource = finding.ipAddress ? null : finding.qualysIpv6 ? 'Q' : finding.primaryIpv6 ? 'v6' : null;
|
||
const hoverIp = displayIp || null;
|
||
return (
|
||
<td
|
||
style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', cursor: hoverIp ? 'help' : 'default' }}
|
||
onMouseEnter={onIpMouseEnter && hoverIp ? (e) => onIpMouseEnter(hoverIp, e, finding.hostId) : undefined}
|
||
onMouseLeave={onIpMouseLeave || undefined}
|
||
title={ipSource === 'Q' ? 'Qualys IPv6 (no IPv4 available)' : ipSource === 'v6' ? 'Primary IPv6 (no IPv4 available)' : undefined}
|
||
>
|
||
{displayIp ? (
|
||
<>
|
||
<span style={{ maxWidth: '140px', overflow: 'hidden', textOverflow: 'ellipsis', display: 'inline-block', verticalAlign: 'middle' }}>{displayIp}</span>
|
||
{ipSource && (
|
||
<span style={{ marginLeft: '0.3rem', fontSize: '0.55rem', padding: '0.08rem 0.25rem', borderRadius: '0.2rem', background: ipSource === 'Q' ? 'rgba(245, 158, 11, 0.15)' : 'rgba(99, 102, 241, 0.15)', border: ipSource === 'Q' ? '1px solid rgba(245, 158, 11, 0.4)' : '1px solid rgba(99, 102, 241, 0.4)', color: ipSource === 'Q' ? '#FBBF24' : '#A5B4FC', fontWeight: '700', verticalAlign: 'middle' }}>
|
||
{ipSource}
|
||
</span>
|
||
)}
|
||
</>
|
||
) : '—'}
|
||
</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} onNoteSaved={onNoteSaved} />
|
||
</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' || queueForm.workflowType === 'GRANITE' || queueForm.workflowType === 'DECOM';
|
||
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' },
|
||
{ key: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
|
||
{ key: 'DECOM', col: '#EF4444', rgb: '239,68,68' },
|
||
{ key: 'Remediate', col: '#A855F7', rgb: '168,85,247' },
|
||
].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, onDismissSubmission, cardConfigured, cardTeams, onQueueRefresh }) {
|
||
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);
|
||
|
||
// CARD action state — tracks which item has an active action form
|
||
const [cardAction, setCardAction] = useState(null); // { itemId, type: 'confirm'|'decline'|'redirect' }
|
||
const [cardFormTeam, setCardFormTeam] = useState('');
|
||
const [cardFormComment, setCardFormComment] = useState('');
|
||
const [cardFormFromTeam, setCardFormFromTeam] = useState('');
|
||
const [cardFormToTeam, setCardFormToTeam] = useState('');
|
||
const [cardActionLoading, setCardActionLoading] = useState(false);
|
||
const [cardActionError, setCardActionError] = useState(null);
|
||
|
||
// Granite Loader Sheet modal state
|
||
const [showLoaderModal, setShowLoaderModal] = useState(false);
|
||
|
||
// CARD Action Modal state
|
||
const [cardModalItem, setCardModalItem] = useState(null);
|
||
const [cardModalAction, setCardModalAction] = useState('confirm');
|
||
|
||
// Create Jira modal state
|
||
const [createJiraOpen, setCreateJiraOpen] = useState(false);
|
||
const [createJiraForm, setCreateJiraForm] = useState({ summary: '', cve_id: '', vendor: '', source_context: 'ivanti_queue', description: '', project_key: '', issue_type: '' });
|
||
const [createJiraError, setCreateJiraError] = useState(null);
|
||
|
||
// Remediation Modal state
|
||
const [remediationModalItem, setRemediationModalItem] = useState(null);
|
||
const [createJiraSaving, setCreateJiraSaving] = useState(false);
|
||
const [createJiraSummaryError, setCreateJiraSummaryError] = useState(null);
|
||
|
||
// Consolidated Jira ticket modal state (multi-item → 1 ticket)
|
||
const [showConsolidationModal, setShowConsolidationModal] = useState(false);
|
||
const [consolidationSuccess, setConsolidationSuccess] = useState(null); // { ticket_key, jira_url }
|
||
|
||
// CARD Asset Search state
|
||
const [assetSearchOpen, setAssetSearchOpen] = useState(false);
|
||
const [assetSearchTeam, setAssetSearchTeam] = useState('');
|
||
const [assetSearchDisposition, setAssetSearchDisposition] = useState('confirmed');
|
||
const [assetSearchResults, setAssetSearchResults] = useState(null);
|
||
const [assetSearchLoading, setAssetSearchLoading] = useState(false);
|
||
const [assetSearchError, setAssetSearchError] = useState(null);
|
||
const [assetSearchPage, setAssetSearchPage] = useState(1);
|
||
|
||
// 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());
|
||
};
|
||
|
||
// Submissions section — collapsible state (Task 6)
|
||
const [submissionsCollapsed, setSubmissionsCollapsed] = useState(() => localStorage.getItem('steam_submissions_collapsed') === 'true');
|
||
const [dismissError, setDismissError] = useState(null);
|
||
|
||
// Collapsible section state for queue groups
|
||
const [collapsedSections, setCollapsedSections] = useState({});
|
||
const toggleSectionCollapse = (sectionKey) => {
|
||
setCollapsedSections((prev) => ({ ...prev, [sectionKey]: !prev[sectionKey] }));
|
||
};
|
||
|
||
const toggleSubmissionsCollapsed = () => {
|
||
setSubmissionsCollapsed(prev => {
|
||
const next = !prev;
|
||
try { localStorage.setItem('steam_submissions_collapsed', String(next)); } catch { /* ignore */ }
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// Dismiss handler (Task 5)
|
||
const handleDismiss = async (e, submissionId) => {
|
||
e.stopPropagation();
|
||
setDismissError(null);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submissionId}/dismiss`, {
|
||
method: 'PATCH',
|
||
credentials: 'include',
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
setDismissError(data.error || 'Failed to dismiss submission');
|
||
return;
|
||
}
|
||
if (onDismissSubmission) onDismissSubmission(submissionId);
|
||
} catch (err) {
|
||
setDismissError('Network error — could not dismiss submission');
|
||
}
|
||
};
|
||
const handleRedirectSuccess = (newItem) => {
|
||
if (onRedirectComplete) onRedirectComplete(newItem);
|
||
setRedirectItem(null);
|
||
setRedirectSuccess(`Redirected to ${newItem.workflow_type}`);
|
||
setTimeout(() => setRedirectSuccess(null), 3000);
|
||
};
|
||
|
||
// CARD action handlers — open the CardActionModal instead of inline form
|
||
const openCardAction = (itemId, type) => {
|
||
const targetItem = items.find(i => i.id === itemId);
|
||
if (targetItem) {
|
||
setCardModalItem(targetItem);
|
||
setCardModalAction(type);
|
||
}
|
||
};
|
||
|
||
const closeCardAction = () => {
|
||
setCardAction(null);
|
||
setCardFormTeam('');
|
||
setCardFormComment('');
|
||
setCardFormFromTeam('');
|
||
setCardFormToTeam('');
|
||
setCardActionError(null);
|
||
setCardActionLoading(false);
|
||
};
|
||
|
||
const handleCardConfirmDecline = async (item, actionType) => {
|
||
if (!cardFormTeam) return;
|
||
if (!item.ip_address) {
|
||
setCardActionError('No IP address on this queue item — cannot resolve CARD asset.');
|
||
return;
|
||
}
|
||
setCardActionLoading(true);
|
||
setCardActionError(null);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/card/queue/${item.id}/${actionType}`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
teamName: cardFormTeam,
|
||
assetId: item.ip_address,
|
||
comment: cardFormComment || '',
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
const errorMsg = data.error || data.message || (typeof data === 'string' ? data : `${actionType} failed.`);
|
||
setCardActionError(errorMsg);
|
||
setCardActionLoading(false);
|
||
return;
|
||
}
|
||
// Update local state to complete without full refresh
|
||
onUpdate(item.id, { status: 'complete' });
|
||
closeCardAction();
|
||
} catch (err) {
|
||
setCardActionError(err.message || 'Network error.');
|
||
setCardActionLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleCardRedirect = async (item) => {
|
||
if (!cardFormFromTeam || !cardFormToTeam) return;
|
||
if (!item.ip_address) {
|
||
setCardActionError('No IP address on this queue item — cannot resolve CARD asset.');
|
||
return;
|
||
}
|
||
setCardActionLoading(true);
|
||
setCardActionError(null);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/card/queue/${item.id}/redirect`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
fromTeam: cardFormFromTeam,
|
||
toTeam: cardFormToTeam,
|
||
assetId: item.ip_address,
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
const errorMsg = data.error || data.message || (typeof data === 'string' ? data : JSON.stringify(data));
|
||
setCardActionError(errorMsg);
|
||
setCardActionLoading(false);
|
||
return;
|
||
}
|
||
onUpdate(item.id, { status: 'complete' });
|
||
closeCardAction();
|
||
} catch (err) {
|
||
setCardActionError(err.message || 'Network error.');
|
||
setCardActionLoading(false);
|
||
}
|
||
};
|
||
|
||
// CARD Asset Search handler
|
||
const handleAssetSearch = async (page = 1) => {
|
||
if (!assetSearchTeam || !assetSearchDisposition) return;
|
||
setAssetSearchLoading(true);
|
||
setAssetSearchError(null);
|
||
setAssetSearchPage(page);
|
||
try {
|
||
const res = await fetch(
|
||
`${API_BASE}/card/teams/${encodeURIComponent(assetSearchTeam)}/assets?disposition=${encodeURIComponent(assetSearchDisposition)}&page_size=50&page=${page}`,
|
||
{ credentials: 'include' }
|
||
);
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
setAssetSearchError(data.error || 'Search failed.');
|
||
setAssetSearchLoading(false);
|
||
return;
|
||
}
|
||
setAssetSearchResults(data);
|
||
} catch (err) {
|
||
setAssetSearchError(err.message || 'Network error.');
|
||
} finally {
|
||
setAssetSearchLoading(false);
|
||
}
|
||
};
|
||
|
||
// Open Create Jira modal pre-populated from a queue item
|
||
const openCreateJiraFromQueue = async (item) => {
|
||
// Parse cves_json — it may be a JSON string or already an array
|
||
let cves = [];
|
||
if (item.cves_json) {
|
||
try { cves = typeof item.cves_json === 'string' ? JSON.parse(item.cves_json) : item.cves_json; } catch { cves = []; }
|
||
} else if (item.cves && Array.isArray(item.cves)) {
|
||
cves = item.cves;
|
||
}
|
||
const firstCve = (Array.isArray(cves) && cves.length > 0) ? cves[0] : '';
|
||
const summary = (item.finding_title || '').slice(0, 255);
|
||
|
||
// Build description — include finding details and remediation notes for Remediate items
|
||
let description = '';
|
||
// Always include finding info in description
|
||
const cveList = (Array.isArray(cves) && cves.length > 0) ? cves.join(', ') : 'None';
|
||
description += `== ${item.vendor || 'Unknown Vendor'} ==\n`;
|
||
description += `- ${item.finding_title || 'Untitled'}\n`;
|
||
description += ` CVEs: ${cveList}\n`;
|
||
description += ` Host: ${item.hostname || 'N/A'} (${item.ip_address || 'N/A'})\n`;
|
||
|
||
if (item.workflow_type === 'Remediate') {
|
||
try {
|
||
const notesRes = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, { credentials: 'include' });
|
||
if (notesRes.ok) {
|
||
const notes = await notesRes.json();
|
||
if (notes.length > 0) {
|
||
const sorted = [...notes].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||
description += '\n== Remediation Notes ==\n';
|
||
for (const note of sorted) {
|
||
const date = note.created_at ? note.created_at.slice(0, 10) : 'Unknown';
|
||
description += `[${date}] ${note.username}: ${note.note_text}\n`;
|
||
}
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
|
||
setCreateJiraForm({
|
||
summary,
|
||
cve_id: firstCve,
|
||
vendor: item.vendor || '',
|
||
source_context: 'ivanti_queue',
|
||
description,
|
||
project_key: '',
|
||
issue_type: '',
|
||
});
|
||
setCreateJiraError(null);
|
||
setCreateJiraSummaryError(null);
|
||
setCreateJiraOpen(true);
|
||
};
|
||
|
||
// Submit the Create Jira form
|
||
const submitCreateJira = async () => {
|
||
const trimmedSummary = (createJiraForm.summary || '').trim();
|
||
if (!trimmedSummary) {
|
||
setCreateJiraSummaryError('Summary is required.');
|
||
return;
|
||
}
|
||
if (trimmedSummary.length > 255) {
|
||
setCreateJiraSummaryError('Summary must be 255 characters or fewer.');
|
||
return;
|
||
}
|
||
setCreateJiraSummaryError(null);
|
||
setCreateJiraError(null);
|
||
setCreateJiraSaving(true);
|
||
try {
|
||
const payload = { ...createJiraForm };
|
||
if (!payload.source_context) delete payload.source_context;
|
||
const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||
setCreateJiraOpen(false);
|
||
setCreateJiraForm({ summary: '', cve_id: '', vendor: '', source_context: 'ivanti_queue', description: '', project_key: '', issue_type: '' });
|
||
} catch (err) {
|
||
setCreateJiraError(err.message);
|
||
} finally {
|
||
setCreateJiraSaving(false);
|
||
}
|
||
};
|
||
|
||
// Render a single queue item row
|
||
const renderQueueItem = (item, { done, selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }) => {
|
||
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
|
||
: item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' }
|
||
: item.workflow_type === 'GRANITE' ? { col: '#A1887F', rgb: '161,136,127' }
|
||
: item.workflow_type === 'DECOM' ? { col: '#EF4444', rgb: '239,68,68' }
|
||
: item.workflow_type === 'Remediate' ? { col: '#A855F7', rgb: '168,85,247' }
|
||
: { 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 isInventoryItem = item.workflow_type === 'CARD' || item.workflow_type === 'GRANITE' || item.workflow_type === 'DECOM';
|
||
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>
|
||
{isInventoryItem ? (
|
||
<>
|
||
{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: '2px',
|
||
}} title={cves.join(', ')}>
|
||
{cveDisplay}
|
||
</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>
|
||
|
||
{/* CARD action buttons — pending CARD items only */}
|
||
{item.workflow_type === 'CARD' && item.status === 'pending' && canWrite && (
|
||
<div style={{ display: 'flex', gap: '0.25rem', flexShrink: 0 }}>
|
||
<button
|
||
onClick={() => openCardAction(item.id, 'confirm')}
|
||
disabled={!cardConfigured || cardActionLoading}
|
||
title={!cardConfigured ? 'CARD integration not configured' : 'Confirm asset'}
|
||
style={{
|
||
background: cardConfigured ? 'rgba(16,185,129,0.1)' : 'transparent',
|
||
border: `1px solid ${cardConfigured ? 'rgba(16,185,129,0.3)' : 'rgba(255,255,255,0.05)'}`,
|
||
borderRadius: '0.2rem',
|
||
padding: '0.15rem 0.3rem',
|
||
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
|
||
color: cardConfigured ? '#10B981' : '#334155',
|
||
cursor: cardConfigured ? 'pointer' : 'not-allowed',
|
||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||
transition: 'all 0.12s',
|
||
}}
|
||
>
|
||
Confirm
|
||
</button>
|
||
<button
|
||
onClick={() => openCardAction(item.id, 'decline')}
|
||
disabled={!cardConfigured || cardActionLoading}
|
||
title={!cardConfigured ? 'CARD integration not configured' : 'Decline asset'}
|
||
style={{
|
||
background: cardConfigured ? 'rgba(239,68,68,0.1)' : 'transparent',
|
||
border: `1px solid ${cardConfigured ? 'rgba(239,68,68,0.3)' : 'rgba(255,255,255,0.05)'}`,
|
||
borderRadius: '0.2rem',
|
||
padding: '0.15rem 0.3rem',
|
||
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
|
||
color: cardConfigured ? '#EF4444' : '#334155',
|
||
cursor: cardConfigured ? 'pointer' : 'not-allowed',
|
||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||
transition: 'all 0.12s',
|
||
}}
|
||
>
|
||
Decline
|
||
</button>
|
||
<button
|
||
onClick={() => openCardAction(item.id, 'redirect')}
|
||
disabled={!cardConfigured || cardActionLoading}
|
||
title={!cardConfigured ? 'CARD integration not configured' : 'Redirect asset'}
|
||
style={{
|
||
background: cardConfigured ? 'rgba(14,165,233,0.1)' : 'transparent',
|
||
border: `1px solid ${cardConfigured ? 'rgba(14,165,233,0.3)' : 'rgba(255,255,255,0.05)'}`,
|
||
borderRadius: '0.2rem',
|
||
padding: '0.15rem 0.3rem',
|
||
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
|
||
color: cardConfigured ? '#0EA5E9' : '#334155',
|
||
cursor: cardConfigured ? 'pointer' : 'not-allowed',
|
||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||
transition: 'all 0.12s',
|
||
}}
|
||
>
|
||
Redirect
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Remediation Notes button — Remediate items only */}
|
||
{item.workflow_type === 'Remediate' && (
|
||
<button
|
||
onClick={() => setRemediationModalItem(item)}
|
||
style={{
|
||
background: 'rgba(168, 85, 247, 0.08)',
|
||
border: '1px solid rgba(168, 85, 247, 0.25)',
|
||
borderRadius: '0.2rem',
|
||
padding: '0.15rem 0.35rem',
|
||
cursor: 'pointer',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '0.2rem',
|
||
color: '#C084FC',
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.55rem',
|
||
fontWeight: '700',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.04em',
|
||
flexShrink: 0,
|
||
transition: 'all 0.12s',
|
||
position: 'relative',
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(168, 85, 247, 0.18)'; e.currentTarget.style.borderColor = 'rgba(168, 85, 247, 0.45)'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(168, 85, 247, 0.08)'; e.currentTarget.style.borderColor = 'rgba(168, 85, 247, 0.25)'; }}
|
||
title="View remediation notes"
|
||
>
|
||
<FileText style={{ width: '10px', height: '10px' }} />
|
||
Notes
|
||
{(item.remediation_notes_count || 0) > 0 && (
|
||
<span style={{
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.5rem',
|
||
fontWeight: 700,
|
||
color: '#A855F7',
|
||
background: 'rgba(168, 85, 247, 0.15)',
|
||
border: '1px solid rgba(168, 85, 247, 0.3)',
|
||
borderRadius: '999px',
|
||
padding: '0 0.25rem',
|
||
marginLeft: '0.1rem',
|
||
}}>
|
||
{item.remediation_notes_count > 99 ? '99+' : item.remediation_notes_count}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
|
||
{/* Redirect button — available on all items */}
|
||
{canWrite && (
|
||
<button
|
||
onClick={() => setRedirectItem(item)}
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: done ? '#334155' : '#475569', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||
onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'}
|
||
onMouseLeave={(e) => e.currentTarget.style.color = done ? '#334155' : '#475569'}
|
||
title="Redirect to another workflow"
|
||
>
|
||
<CornerUpRight style={{ width: '13px', height: '13px' }} />
|
||
</button>
|
||
)}
|
||
|
||
{/* Create Jira Ticket button — pending items only */}
|
||
{canWrite && !done && (
|
||
<button
|
||
onClick={() => openCreateJiraFromQueue(item)}
|
||
style={{
|
||
background: 'rgba(14, 165, 233, 0.08)',
|
||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||
borderRadius: '0.2rem',
|
||
padding: '0.15rem 0.35rem',
|
||
cursor: 'pointer',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '0.2rem',
|
||
color: '#7DD3FC',
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.55rem',
|
||
fontWeight: '700',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.04em',
|
||
flexShrink: 0,
|
||
transition: 'all 0.12s',
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.18)'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.45)'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.08)'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
|
||
title="Create Jira ticket from this queue item"
|
||
>
|
||
<Plus style={{ width: '10px', height: '10px' }} />
|
||
Jira
|
||
</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>
|
||
);
|
||
};
|
||
|
||
// Render CARD action inline form below a queue item
|
||
const renderCardActionForm = (item) => {
|
||
if (!cardAction || cardAction.itemId !== item.id) return null;
|
||
const { type } = cardAction;
|
||
|
||
if (type === 'confirm' || type === 'decline') {
|
||
const accentColor = type === 'confirm' ? '#10B981' : '#EF4444';
|
||
const accentRgb = type === 'confirm' ? '16,185,129' : '239,68,68';
|
||
const canSubmit = !cardActionLoading && cardFormTeam.length > 0;
|
||
return (
|
||
<div style={{
|
||
padding: '0.5rem 0.625rem',
|
||
marginBottom: '0.25rem',
|
||
borderRadius: '0.375rem',
|
||
background: `rgba(${accentRgb},0.04)`,
|
||
border: `1px solid rgba(${accentRgb},0.15)`,
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '0.375rem' }}>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '700', color: accentColor, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||
{type === 'confirm' ? 'Confirm Asset' : 'Decline Asset'}
|
||
</span>
|
||
</div>
|
||
<select
|
||
value={cardFormTeam}
|
||
onChange={(e) => setCardFormTeam(e.target.value)}
|
||
disabled={cardActionLoading}
|
||
style={{
|
||
width: '100%', boxSizing: 'border-box',
|
||
background: 'rgba(15, 23, 42, 0.6)',
|
||
border: `1px solid rgba(${accentRgb}, 0.25)`,
|
||
borderRadius: '0.25rem',
|
||
padding: '0.35rem 0.5rem',
|
||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
|
||
color: '#E2E8F0',
|
||
outline: 'none',
|
||
marginBottom: '0.375rem',
|
||
}}
|
||
>
|
||
<option value="">Select team…</option>
|
||
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
<input
|
||
type="text"
|
||
value={cardFormComment}
|
||
onChange={(e) => setCardFormComment(e.target.value)}
|
||
disabled={cardActionLoading}
|
||
placeholder="Comment (optional)"
|
||
maxLength={500}
|
||
style={{
|
||
width: '100%', boxSizing: 'border-box',
|
||
background: 'rgba(15, 23, 42, 0.6)',
|
||
border: `1px solid rgba(${accentRgb}, 0.15)`,
|
||
borderRadius: '0.25rem',
|
||
padding: '0.35rem 0.5rem',
|
||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
|
||
color: '#E2E8F0',
|
||
outline: 'none',
|
||
marginBottom: '0.375rem',
|
||
}}
|
||
/>
|
||
{cardActionError && (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.3rem 0.5rem',
|
||
background: 'rgba(239, 68, 68, 0.1)',
|
||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||
borderRadius: '0.25rem',
|
||
marginBottom: '0.375rem',
|
||
}}>
|
||
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{cardActionError}</span>
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', gap: '0.375rem' }}>
|
||
<button
|
||
onClick={() => handleCardConfirmDecline(item, type)}
|
||
disabled={!canSubmit}
|
||
style={{
|
||
flex: 1, padding: '0.3rem',
|
||
background: canSubmit ? `rgba(${accentRgb},0.12)` : 'transparent',
|
||
border: `1px solid ${canSubmit ? `rgba(${accentRgb},0.35)` : 'rgba(255,255,255,0.05)'}`,
|
||
borderRadius: '0.25rem',
|
||
color: canSubmit ? accentColor : '#334155',
|
||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
|
||
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem',
|
||
}}
|
||
>
|
||
{cardActionLoading && <Loader style={{ width: '10px', height: '10px', animation: 'spin 1s linear infinite' }} />}
|
||
{cardActionLoading ? 'Submitting…' : type === 'confirm' ? 'Confirm' : 'Decline'}
|
||
</button>
|
||
<button
|
||
onClick={closeCardAction}
|
||
disabled={cardActionLoading}
|
||
style={{
|
||
padding: '0.3rem 0.5rem',
|
||
background: 'transparent',
|
||
border: '1px solid rgba(255,255,255,0.08)',
|
||
borderRadius: '0.25rem',
|
||
color: '#64748B',
|
||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
|
||
cursor: 'pointer',
|
||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||
}}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (type === 'redirect') {
|
||
const canSubmit = !cardActionLoading && cardFormFromTeam.length > 0 && cardFormToTeam.length > 0;
|
||
return (
|
||
<div style={{
|
||
padding: '0.5rem 0.625rem',
|
||
marginBottom: '0.25rem',
|
||
borderRadius: '0.375rem',
|
||
background: 'rgba(14,165,233,0.04)',
|
||
border: '1px solid rgba(14,165,233,0.15)',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '0.375rem' }}>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||
Redirect Asset
|
||
</span>
|
||
</div>
|
||
<select
|
||
value={cardFormFromTeam}
|
||
onChange={(e) => setCardFormFromTeam(e.target.value)}
|
||
disabled={cardActionLoading}
|
||
style={{
|
||
width: '100%', boxSizing: 'border-box',
|
||
background: 'rgba(15, 23, 42, 0.6)',
|
||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||
borderRadius: '0.25rem',
|
||
padding: '0.35rem 0.5rem',
|
||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
|
||
color: '#E2E8F0',
|
||
outline: 'none',
|
||
marginBottom: '0.375rem',
|
||
}}
|
||
>
|
||
<option value="">From team…</option>
|
||
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
<select
|
||
value={cardFormToTeam}
|
||
onChange={(e) => setCardFormToTeam(e.target.value)}
|
||
disabled={cardActionLoading}
|
||
style={{
|
||
width: '100%', boxSizing: 'border-box',
|
||
background: 'rgba(15, 23, 42, 0.6)',
|
||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||
borderRadius: '0.25rem',
|
||
padding: '0.35rem 0.5rem',
|
||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
|
||
color: '#E2E8F0',
|
||
outline: 'none',
|
||
marginBottom: '0.375rem',
|
||
}}
|
||
>
|
||
<option value="">To team…</option>
|
||
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
{cardActionError && (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.3rem 0.5rem',
|
||
background: 'rgba(239, 68, 68, 0.1)',
|
||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||
borderRadius: '0.25rem',
|
||
marginBottom: '0.375rem',
|
||
}}>
|
||
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{cardActionError}</span>
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', gap: '0.375rem' }}>
|
||
<button
|
||
onClick={() => handleCardRedirect(item)}
|
||
disabled={!canSubmit}
|
||
style={{
|
||
flex: 1, padding: '0.3rem',
|
||
background: canSubmit ? 'rgba(14,165,233,0.12)' : 'transparent',
|
||
border: `1px solid ${canSubmit ? 'rgba(14,165,233,0.35)' : 'rgba(255,255,255,0.05)'}`,
|
||
borderRadius: '0.25rem',
|
||
color: canSubmit ? '#0EA5E9' : '#334155',
|
||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
|
||
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem',
|
||
}}
|
||
>
|
||
{cardActionLoading && <Loader style={{ width: '10px', height: '10px', animation: 'spin 1s linear infinite' }} />}
|
||
{cardActionLoading ? 'Redirecting…' : 'Redirect'}
|
||
</button>
|
||
<button
|
||
onClick={closeCardAction}
|
||
disabled={cardActionLoading}
|
||
style={{
|
||
padding: '0.3rem 0.5rem',
|
||
background: 'transparent',
|
||
border: '1px solid rgba(255,255,255,0.08)',
|
||
borderRadius: '0.25rem',
|
||
color: '#64748B',
|
||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
|
||
cursor: 'pointer',
|
||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||
}}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
// Inventory items (CARD + GRANITE + DECOM) are their own top section; everything else groups by vendor
|
||
const grouped = useMemo(() => {
|
||
const inventoryItems = items.filter((i) => i.workflow_type === 'CARD' || i.workflow_type === 'GRANITE' || i.workflow_type === 'DECOM');
|
||
const cardItems = inventoryItems.filter((i) => i.workflow_type === 'CARD');
|
||
const graniteItems = inventoryItems.filter((i) => i.workflow_type === 'GRANITE');
|
||
const decomItems = inventoryItems.filter((i) => i.workflow_type === 'DECOM');
|
||
const otherItems = items.filter((i) => i.workflow_type !== 'CARD' && i.workflow_type !== 'GRANITE' && i.workflow_type !== 'DECOM');
|
||
|
||
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], isInventory: false,
|
||
}));
|
||
|
||
return inventoryItems.length > 0
|
||
? [{ key: '__INVENTORY__', label: 'Inventory', cardItems, graniteItems, decomItems, items: inventoryItems, isInventory: 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: '600px',
|
||
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, isInventory, cardItems, graniteItems, decomItems }) => (
|
||
<div key={key} style={{ marginBottom: '1.25rem' }}>
|
||
{/* Group header — clickable to collapse/expand */}
|
||
<div
|
||
onClick={() => toggleSectionCollapse(key)}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.3rem 0', marginBottom: '0.375rem',
|
||
borderBottom: `1px solid ${isInventory ? 'rgba(16,185,129,0.2)' : 'rgba(255,255,255,0.06)'}`,
|
||
cursor: 'pointer', userSelect: 'none',
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSectionCollapse(key); } }}
|
||
aria-expanded={!collapsedSections[key]}
|
||
aria-label={`${label} section, ${groupItems.length} items`}
|
||
>
|
||
{collapsedSections[key]
|
||
? <ChevronRight style={{ width: '12px', height: '12px', color: isInventory ? '#10B981' : '#64748B' }} />
|
||
: <ChevronDown style={{ width: '12px', height: '12px', color: isInventory ? '#10B981' : '#64748B' }} />
|
||
}
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: isInventory ? '#10B981' : '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em', flex: 1 }}>
|
||
{label}
|
||
</span>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||
{groupItems.length}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Items — only rendered when section is expanded */}
|
||
{!collapsedSections[key] && (isInventory ? (
|
||
<>
|
||
{cardItems.map((item) => (
|
||
<React.Fragment key={item.id}>
|
||
{renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite })}
|
||
{renderCardActionForm(item)}
|
||
</React.Fragment>
|
||
))}
|
||
{cardItems.length > 0 && graniteItems.length > 0 && (
|
||
<div style={{
|
||
height: '1px',
|
||
background: 'rgba(161,136,127,0.18)',
|
||
margin: '0.5rem 0.625rem',
|
||
}} />
|
||
)}
|
||
{graniteItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))}
|
||
{(cardItems.length > 0 || graniteItems.length > 0) && decomItems.length > 0 && (
|
||
<div style={{
|
||
height: '1px',
|
||
background: 'rgba(239,68,68,0.18)',
|
||
margin: '0.5rem 0.625rem',
|
||
}} />
|
||
)}
|
||
{decomItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))}
|
||
</>
|
||
) : (
|
||
groupItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* CARD Asset Search section */}
|
||
{cardConfigured && (
|
||
<div style={{ padding: '0 1.25rem 0.75rem' }}>
|
||
<div
|
||
onClick={() => setAssetSearchOpen(!assetSearchOpen)}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '0.3rem 0', marginBottom: '0.375rem',
|
||
borderBottom: '1px solid rgba(14,165,233,0.2)',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||
<Database style={{ width: '12px', height: '12px', color: '#0EA5E9' }} />
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||
CARD Asset Search
|
||
</span>
|
||
</div>
|
||
{assetSearchOpen
|
||
? <ChevronUp style={{ width: '14px', height: '14px', color: '#475569' }} />
|
||
: <ChevronDown style={{ width: '14px', height: '14px', color: '#475569' }} />
|
||
}
|
||
</div>
|
||
|
||
{assetSearchOpen && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||
<select
|
||
value={assetSearchTeam}
|
||
onChange={(e) => { setAssetSearchTeam(e.target.value); setAssetSearchResults(null); setAssetSearchError(null); }}
|
||
style={{
|
||
width: '100%', boxSizing: 'border-box',
|
||
background: 'rgba(15, 23, 42, 0.6)',
|
||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||
borderRadius: '0.25rem',
|
||
padding: '0.35rem 0.5rem',
|
||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
|
||
color: '#E2E8F0',
|
||
outline: 'none',
|
||
}}
|
||
>
|
||
<option value="">Select team…</option>
|
||
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
<select
|
||
value={assetSearchDisposition}
|
||
onChange={(e) => { setAssetSearchDisposition(e.target.value); setAssetSearchResults(null); setAssetSearchError(null); }}
|
||
style={{
|
||
width: '100%', boxSizing: 'border-box',
|
||
background: 'rgba(15, 23, 42, 0.6)',
|
||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||
borderRadius: '0.25rem',
|
||
padding: '0.35rem 0.5rem',
|
||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
|
||
color: '#E2E8F0',
|
||
outline: 'none',
|
||
}}
|
||
>
|
||
<option value="confirmed">Confirmed</option>
|
||
<option value="unconfirmed">Unconfirmed</option>
|
||
<option value="declined">Declined</option>
|
||
<option value="candidate">Candidate</option>
|
||
</select>
|
||
<button
|
||
onClick={() => handleAssetSearch(1)}
|
||
disabled={!assetSearchTeam || assetSearchLoading}
|
||
style={{
|
||
padding: '0.35rem',
|
||
background: assetSearchTeam ? 'rgba(14,165,233,0.12)' : 'transparent',
|
||
border: `1px solid ${assetSearchTeam ? 'rgba(14,165,233,0.35)' : 'rgba(255,255,255,0.05)'}`,
|
||
borderRadius: '0.25rem',
|
||
color: assetSearchTeam ? '#0EA5E9' : '#334155',
|
||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||
cursor: assetSearchTeam ? 'pointer' : 'not-allowed',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem',
|
||
}}
|
||
>
|
||
{assetSearchLoading
|
||
? <Loader style={{ width: '12px', height: '12px', animation: 'spin 1s linear infinite' }} />
|
||
: <Search style={{ width: '12px', height: '12px' }} />
|
||
}
|
||
{assetSearchLoading ? 'Searching…' : 'Search'}
|
||
</button>
|
||
|
||
{/* Error */}
|
||
{assetSearchError && (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.3rem 0.5rem',
|
||
background: 'rgba(239, 68, 68, 0.1)',
|
||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||
borderRadius: '0.25rem',
|
||
}}>
|
||
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{assetSearchError}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Results */}
|
||
{assetSearchResults && (
|
||
<div>
|
||
<div style={{
|
||
fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600',
|
||
color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||
marginBottom: '0.25rem',
|
||
}}>
|
||
{assetSearchResults.total != null ? `${assetSearchResults.total} asset${assetSearchResults.total !== 1 ? 's' : ''} found` : 'Results'}
|
||
</div>
|
||
|
||
{/* Results table */}
|
||
{Array.isArray(assetSearchResults.assets) && assetSearchResults.assets.length > 0 ? (
|
||
<div style={{
|
||
maxHeight: '200px', overflowY: 'auto',
|
||
border: '1px solid rgba(14,165,233,0.12)',
|
||
borderRadius: '0.25rem',
|
||
}}>
|
||
<table style={{
|
||
width: '100%', borderCollapse: 'collapse',
|
||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.62rem',
|
||
}}>
|
||
<thead>
|
||
<tr style={{ background: 'rgba(14,165,233,0.06)' }}>
|
||
<th style={{ padding: '0.3rem 0.5rem', textAlign: 'left', color: '#94A3B8', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.06em', borderBottom: '1px solid rgba(14,165,233,0.12)' }}>
|
||
Asset ID
|
||
</th>
|
||
{assetSearchResults.assets[0] && Object.keys(assetSearchResults.assets[0]).filter(k => k !== 'asset_id' && k !== '_id').slice(0, 3).map(k => (
|
||
<th key={k} style={{ padding: '0.3rem 0.5rem', textAlign: 'left', color: '#94A3B8', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.06em', borderBottom: '1px solid rgba(14,165,233,0.12)' }}>
|
||
{k.replace(/_/g, ' ')}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{assetSearchResults.assets.map((asset, idx) => {
|
||
const extraKeys = Object.keys(asset).filter(k => k !== 'asset_id' && k !== '_id').slice(0, 3);
|
||
return (
|
||
<tr key={asset.asset_id || asset._id || idx} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
|
||
<td style={{ padding: '0.25rem 0.5rem', color: '#CBD5E1', fontWeight: '600' }}>
|
||
{asset.asset_id || asset._id || '—'}
|
||
</td>
|
||
{extraKeys.map(k => (
|
||
<td key={k} style={{ padding: '0.25rem 0.5rem', color: '#94A3B8' }}>
|
||
{typeof asset[k] === 'object' ? JSON.stringify(asset[k]) : String(asset[k] ?? '—')}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', padding: '0.5rem 0' }}>
|
||
No assets found.
|
||
</div>
|
||
)}
|
||
|
||
{/* Pagination */}
|
||
{assetSearchResults.total != null && assetSearchResults.total > (assetSearchResults.page_size || 50) && (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
|
||
marginTop: '0.375rem',
|
||
}}>
|
||
<button
|
||
onClick={() => handleAssetSearch(assetSearchPage - 1)}
|
||
disabled={assetSearchPage <= 1 || assetSearchLoading}
|
||
style={{
|
||
padding: '0.2rem 0.4rem',
|
||
background: assetSearchPage > 1 ? 'rgba(14,165,233,0.08)' : 'transparent',
|
||
border: `1px solid ${assetSearchPage > 1 ? 'rgba(14,165,233,0.25)' : 'rgba(255,255,255,0.05)'}`,
|
||
borderRadius: '0.2rem',
|
||
color: assetSearchPage > 1 ? '#0EA5E9' : '#334155',
|
||
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
|
||
cursor: assetSearchPage > 1 ? 'pointer' : 'not-allowed',
|
||
}}
|
||
>
|
||
← Prev
|
||
</button>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#64748B' }}>
|
||
Page {assetSearchPage} of {Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50))}
|
||
</span>
|
||
<button
|
||
onClick={() => handleAssetSearch(assetSearchPage + 1)}
|
||
disabled={assetSearchPage >= Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) || assetSearchLoading}
|
||
style={{
|
||
padding: '0.2rem 0.4rem',
|
||
background: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'rgba(14,165,233,0.08)' : 'transparent',
|
||
border: `1px solid ${assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'rgba(14,165,233,0.25)' : 'rgba(255,255,255,0.05)'}`,
|
||
borderRadius: '0.2rem',
|
||
color: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? '#0EA5E9' : '#334155',
|
||
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
|
||
cursor: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'pointer' : 'not-allowed',
|
||
}}
|
||
>
|
||
Next →
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Submissions section */}
|
||
{fpSubmissions && fpSubmissions.length > 0 && (
|
||
<div style={{ padding: '0 1.25rem 0.75rem' }}>
|
||
<div
|
||
onClick={toggleSubmissionsCollapsed}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '0.3rem 0', marginBottom: '0.375rem',
|
||
borderBottom: '1px solid rgba(245,158,11,0.2)',
|
||
cursor: 'pointer', userSelect: 'none',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||
{submissionsCollapsed
|
||
? <ChevronUp style={{ width: '12px', height: '12px', color: '#F59E0B' }} />
|
||
: <ChevronDown style={{ width: '12px', height: '12px', color: '#F59E0B' }} />
|
||
}
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||
Submissions
|
||
</span>
|
||
</div>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||
{fpSubmissions.length}
|
||
</span>
|
||
</div>
|
||
{dismissError && (
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#EF4444', marginBottom: '0.375rem', padding: '0.25rem 0.5rem', background: 'rgba(239,68,68,0.08)', borderRadius: '0.25rem' }}>
|
||
{dismissError}
|
||
</div>
|
||
)}
|
||
{!submissionsCollapsed && 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;
|
||
const showDismiss = sub.lifecycle_status === 'rejected' && !sub.dismissed_at;
|
||
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>
|
||
{showDismiss && (
|
||
<button
|
||
onClick={(e) => handleDismiss(e, sub.id)}
|
||
title="Dismiss rejected submission"
|
||
style={{
|
||
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
width: '20px', height: '20px',
|
||
background: 'rgba(239,68,68,0.08)',
|
||
border: '1px solid rgba(239,68,68,0.2)',
|
||
borderRadius: '0.2rem',
|
||
cursor: 'pointer',
|
||
padding: 0,
|
||
transition: 'all 0.15s',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = 'rgba(239,68,68,0.2)';
|
||
e.currentTarget.style.borderColor = 'rgba(239,68,68,0.4)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = 'rgba(239,68,68,0.08)';
|
||
e.currentTarget.style.borderColor = 'rgba(239,68,68,0.2)';
|
||
}}
|
||
>
|
||
<X style={{ width: '12px', height: '12px', color: '#EF4444' }} />
|
||
</button>
|
||
)}
|
||
</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={() => {
|
||
// Get selected pending items for consolidated Jira ticket
|
||
const selectedPendingItems = items.filter(i => selectedIds.has(i.id) && i.status === 'pending');
|
||
if (selectedPendingItems.length >= 2) {
|
||
setShowConsolidationModal(true);
|
||
} else if (selectedPendingItems.length === 1) {
|
||
openCreateJiraFromQueue(selectedPendingItems[0]);
|
||
}
|
||
}}
|
||
disabled={(() => {
|
||
const selectedPendingItems = items.filter(i => selectedIds.has(i.id) && i.status === 'pending');
|
||
return selectedPendingItems.length === 0;
|
||
})()}
|
||
style={{
|
||
flex: 1, padding: '0.45rem',
|
||
background: 'rgba(14,165,233,0.1)',
|
||
border: '1px solid rgba(14,165,233,0.35)',
|
||
borderRadius: '0.375rem',
|
||
color: '#0EA5E9',
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||
cursor: 'pointer',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
transition: 'all 0.12s',
|
||
}}
|
||
title={`Create a single Jira ticket from ${selectedIds.size} selected item(s)`}
|
||
>
|
||
+ Jira ({items.filter(i => selectedIds.has(i.id) && i.status === 'pending').length})
|
||
</button>
|
||
)}
|
||
{/* Generate Loader Sheet — visible when CARD/GRANITE/DECOM items are selected or as standalone */}
|
||
{(() => {
|
||
const selectedCardGranite = items.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
|
||
const hasCardGraniteItems = items.some(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
|
||
const isEnabled = selectedCardGranite.length > 0 || hasCardGraniteItems;
|
||
return isEnabled ? (
|
||
<button
|
||
onClick={() => setShowLoaderModal(true)}
|
||
style={{
|
||
flex: 1, padding: '0.45rem',
|
||
background: 'rgba(124,58,237,0.1)',
|
||
border: '1px solid rgba(124,58,237,0.35)',
|
||
borderRadius: '0.375rem',
|
||
color: '#A78BFA',
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||
cursor: 'pointer',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
transition: 'all 0.12s',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.3rem',
|
||
}}
|
||
title="Generate Granite Team_Device Loader Sheet"
|
||
>
|
||
<FileSpreadsheet style={{ width: '12px', height: '12px' }} />
|
||
Loader
|
||
</button>
|
||
) : null;
|
||
})()}
|
||
{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>
|
||
)}
|
||
|
||
{/* Consolidated Jira ticket success notification */}
|
||
{consolidationSuccess && (
|
||
<div style={{
|
||
position: 'fixed', top: '1rem', right: '440px',
|
||
zIndex: 10001,
|
||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||
padding: '0.625rem 1rem',
|
||
background: 'rgba(14, 165, 233, 0.15)',
|
||
border: '1px solid rgba(14, 165, 233, 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: '#0EA5E9',
|
||
}}>
|
||
<Check style={{ width: '14px', height: '14px' }} />
|
||
Jira ticket created:
|
||
<a
|
||
href={consolidationSuccess.jira_url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ color: '#7DD3FC', textDecoration: 'underline' }}
|
||
>
|
||
{consolidationSuccess.ticket_key}
|
||
</a>
|
||
</div>
|
||
)}
|
||
|
||
{/* Redirect modal */}
|
||
{redirectItem && (
|
||
<RedirectModal
|
||
item={redirectItem}
|
||
onClose={() => setRedirectItem(null)}
|
||
onRedirect={handleRedirectSuccess}
|
||
/>
|
||
)}
|
||
|
||
{/* Remediation Notes modal */}
|
||
{remediationModalItem && (
|
||
<RemediationModal
|
||
item={remediationModalItem}
|
||
onClose={() => setRemediationModalItem(null)}
|
||
onNoteAdded={() => {
|
||
if (onQueueRefresh) onQueueRefresh();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Create Jira Ticket modal */}
|
||
{createJiraOpen && (
|
||
<div style={{ position: 'fixed', inset: 0, zIndex: 10100, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)' }} onClick={() => setCreateJiraOpen(false)} />
|
||
<div style={{
|
||
position: 'relative',
|
||
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
|
||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||
borderRadius: '16px',
|
||
padding: '2rem',
|
||
width: '90%',
|
||
maxWidth: '520px',
|
||
maxHeight: '85vh',
|
||
overflowY: 'auto',
|
||
zIndex: 10101,
|
||
}}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
|
||
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '1rem' }}>Create Issue in Jira</h3>
|
||
<button onClick={() => setCreateJiraOpen(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X size={18} /></button>
|
||
</div>
|
||
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginTop: 0, marginBottom: '1rem' }}>
|
||
Creates a new Jira issue from this Ivanti queue item.
|
||
</p>
|
||
{createJiraError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem', padding: '0.5rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '0.375rem' }}>{createJiraError}</div>}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||
<div>
|
||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary <span style={{ color: '#F59E0B' }}>*</span></label>
|
||
<input
|
||
style={{
|
||
background: 'rgba(15, 23, 42, 0.8)',
|
||
border: `1px solid ${createJiraSummaryError ? 'rgba(239, 68, 68, 0.6)' : 'rgba(14, 165, 233, 0.2)'}`,
|
||
borderRadius: '8px',
|
||
padding: '0.5rem 0.75rem',
|
||
color: '#F8FAFC',
|
||
fontSize: '0.85rem',
|
||
width: '100%',
|
||
outline: 'none',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
placeholder="Issue summary (max 255 chars)"
|
||
value={createJiraForm.summary}
|
||
onChange={e => { setCreateJiraForm(f => ({ ...f, summary: e.target.value })); if (createJiraSummaryError) setCreateJiraSummaryError(null); }}
|
||
maxLength={255}
|
||
/>
|
||
{createJiraSummaryError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.25rem' }}>{createJiraSummaryError}</div>}
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID (optional)</label>
|
||
<input
|
||
style={{
|
||
background: 'rgba(15, 23, 42, 0.8)',
|
||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||
borderRadius: '8px',
|
||
padding: '0.5rem 0.75rem',
|
||
color: '#F8FAFC',
|
||
fontSize: '0.85rem',
|
||
width: '100%',
|
||
outline: 'none',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
placeholder="e.g. CVE-2024-12345"
|
||
value={createJiraForm.cve_id}
|
||
onChange={e => setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor (optional)</label>
|
||
<input
|
||
style={{
|
||
background: 'rgba(15, 23, 42, 0.8)',
|
||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||
borderRadius: '8px',
|
||
padding: '0.5rem 0.75rem',
|
||
color: '#F8FAFC',
|
||
fontSize: '0.85rem',
|
||
width: '100%',
|
||
outline: 'none',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
placeholder="e.g. Microsoft"
|
||
value={createJiraForm.vendor}
|
||
onChange={e => setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Source Context</label>
|
||
<select
|
||
style={{
|
||
background: 'rgba(15, 23, 42, 0.8)',
|
||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||
borderRadius: '8px',
|
||
padding: '0.5rem 0.75rem',
|
||
color: '#F8FAFC',
|
||
fontSize: '0.85rem',
|
||
width: '100%',
|
||
outline: 'none',
|
||
boxSizing: 'border-box',
|
||
cursor: 'not-allowed',
|
||
opacity: 0.7,
|
||
}}
|
||
value={createJiraForm.source_context}
|
||
disabled
|
||
>
|
||
<option value="ivanti_queue">Ivanti Queue</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Description (optional)</label>
|
||
<textarea
|
||
style={{
|
||
background: 'rgba(15, 23, 42, 0.8)',
|
||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||
borderRadius: '8px',
|
||
padding: '0.5rem 0.75rem',
|
||
color: '#F8FAFC',
|
||
fontSize: '0.85rem',
|
||
width: '100%',
|
||
outline: 'none',
|
||
boxSizing: 'border-box',
|
||
minHeight: '80px',
|
||
resize: 'vertical',
|
||
}}
|
||
placeholder="Detailed description..."
|
||
value={createJiraForm.description}
|
||
onChange={e => setCreateJiraForm(f => ({ ...f, description: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||
<div>
|
||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Project Key (optional)</label>
|
||
<input
|
||
style={{
|
||
background: 'rgba(15, 23, 42, 0.8)',
|
||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||
borderRadius: '8px',
|
||
padding: '0.5rem 0.75rem',
|
||
color: '#F8FAFC',
|
||
fontSize: '0.85rem',
|
||
width: '100%',
|
||
outline: 'none',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
placeholder="Uses .env default"
|
||
value={createJiraForm.project_key}
|
||
onChange={e => setCreateJiraForm(f => ({ ...f, project_key: e.target.value.toUpperCase() }))}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type (optional)</label>
|
||
<input
|
||
style={{
|
||
background: 'rgba(15, 23, 42, 0.8)',
|
||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||
borderRadius: '8px',
|
||
padding: '0.5rem 0.75rem',
|
||
color: '#F8FAFC',
|
||
fontSize: '0.85rem',
|
||
width: '100%',
|
||
outline: 'none',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
placeholder="Task"
|
||
value={createJiraForm.issue_type}
|
||
onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<button
|
||
style={{
|
||
padding: '0.5rem 1rem',
|
||
borderRadius: '8px',
|
||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
||
background: 'rgba(16, 185, 129, 0.1)',
|
||
color: '#6EE7B7',
|
||
cursor: createJiraSaving ? 'not-allowed' : 'pointer',
|
||
fontSize: '0.8rem',
|
||
fontWeight: 600,
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: '0.4rem',
|
||
transition: 'all 0.2s',
|
||
marginTop: '0.5rem',
|
||
width: '100%',
|
||
}}
|
||
onClick={submitCreateJira}
|
||
disabled={createJiraSaving}
|
||
>
|
||
{createJiraSaving ? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} /> : <Plus size={14} />}
|
||
Create in Jira
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Consolidated Jira Ticket modal (multi-item → 1 ticket) */}
|
||
{showConsolidationModal && (
|
||
<ConsolidationModal
|
||
items={items.filter(i => selectedIds.has(i.id) && i.status === 'pending')}
|
||
onClose={() => setShowConsolidationModal(false)}
|
||
onSuccess={(ticketData) => {
|
||
setShowConsolidationModal(false);
|
||
setSelectedIds(new Set());
|
||
setConsolidationSuccess({ ticket_key: ticketData.ticket_key, jira_url: ticketData.jira_url });
|
||
setTimeout(() => setConsolidationSuccess(null), 8000);
|
||
if (onQueueRefresh) onQueueRefresh();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Granite Loader Sheet Modal */}
|
||
<LoaderModal
|
||
isOpen={showLoaderModal}
|
||
onClose={() => setShowLoaderModal(false)}
|
||
initialDevices={showLoaderModal ? (() => {
|
||
const selected = items.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
|
||
if (selected.length > 0) return selected.map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '', host_id: i.host_id || null }));
|
||
// Standalone: use all CARD/GRANITE/DECOM items
|
||
return items.filter(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '', host_id: i.host_id || null }));
|
||
})() : null}
|
||
/>
|
||
|
||
{/* CARD Action Modal */}
|
||
<CardActionModal
|
||
isOpen={!!cardModalItem}
|
||
onClose={() => setCardModalItem(null)}
|
||
item={cardModalItem}
|
||
initialAction={cardModalAction}
|
||
cardTeams={cardTeams}
|
||
onSuccess={(itemId, _action) => {
|
||
onUpdate(itemId, { status: 'complete' });
|
||
setCardModalItem(null);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AttachmentSourcePicker — shared component for local + library attachments
|
||
// ---------------------------------------------------------------------------
|
||
function AttachmentSourcePicker({ files, onFilesChange, libraryDocs, onLibraryDocsChange, disabled }) {
|
||
const [mode, setMode] = useState('local');
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [searchResults, setSearchResults] = useState([]);
|
||
const [searching, setSearching] = useState(false);
|
||
const [searchError, setSearchError] = useState(null);
|
||
const [fileErrors, setFileErrors] = useState(null);
|
||
const fileInputRef = useRef(null);
|
||
const dropRef = useRef(null);
|
||
const debounceRef = useRef(null);
|
||
|
||
// Format file size helper
|
||
const formatSize = (val) => {
|
||
if (!val && val !== 0) return '0 B';
|
||
// If already a formatted string (e.g. "12.34 KB"), return as-is
|
||
if (typeof val === 'string' && /[A-Za-z]/.test(val)) return val;
|
||
const n = Number(val);
|
||
if (isNaN(n) || n < 0) return '0 B';
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||
};
|
||
|
||
// File validation
|
||
const isAllowedExtension = (filename) => {
|
||
const ext = '.' + filename.split('.').pop().toLowerCase();
|
||
return ALLOWED_EXTENSIONS.includes(ext);
|
||
};
|
||
|
||
const addFiles = (newFiles) => {
|
||
if (disabled) return;
|
||
const errors = [];
|
||
const valid = [];
|
||
Array.from(newFiles).forEach(f => {
|
||
if (!isAllowedExtension(f.name)) {
|
||
errors.push(`"${f.name}" — file type not allowed. Accepted: ${ALLOWED_EXTENSIONS.join(', ')}`);
|
||
} else if (f.size > MAX_FILE_SIZE) {
|
||
errors.push(`"${f.name}" — exceeds 10 MB limit`);
|
||
} else {
|
||
valid.push(f);
|
||
}
|
||
});
|
||
if (errors.length) {
|
||
setFileErrors(errors.join('; '));
|
||
} else {
|
||
setFileErrors(null);
|
||
}
|
||
if (valid.length) onFilesChange([...files, ...valid]);
|
||
};
|
||
|
||
const removeFile = (idx) => {
|
||
if (disabled) return;
|
||
onFilesChange(files.filter((_, i) => i !== idx));
|
||
setFileErrors(null);
|
||
};
|
||
|
||
const removeLibraryDoc = (docId) => {
|
||
if (disabled) return;
|
||
onLibraryDocsChange(libraryDocs.filter(d => d.id !== docId));
|
||
};
|
||
|
||
const handleDrop = (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (disabled) return;
|
||
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
|
||
};
|
||
|
||
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); };
|
||
|
||
// Library search with debounce
|
||
useEffect(() => {
|
||
if (mode !== 'library') return;
|
||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||
|
||
debounceRef.current = setTimeout(async () => {
|
||
setSearching(true);
|
||
setSearchError(null);
|
||
try {
|
||
const url = searchQuery.trim()
|
||
? `${API_BASE}/ivanti/fp-workflow/documents/search?q=${encodeURIComponent(searchQuery.trim())}`
|
||
: `${API_BASE}/ivanti/fp-workflow/documents/search`;
|
||
const res = await fetch(url, { credentials: 'include' });
|
||
if (!res.ok) throw new Error(`Search failed (${res.status})`);
|
||
const data = await res.json();
|
||
setSearchResults(data);
|
||
} catch (err) {
|
||
setSearchError(err.message || 'Failed to search documents');
|
||
setSearchResults([]);
|
||
} finally {
|
||
setSearching(false);
|
||
}
|
||
}, 300);
|
||
|
||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||
}, [searchQuery, mode]);
|
||
|
||
const selectLibraryDoc = (doc) => {
|
||
if (disabled) return;
|
||
if (libraryDocs.some(d => d.id === doc.id)) return;
|
||
onLibraryDocsChange([...libraryDocs, {
|
||
id: doc.id,
|
||
cve_id: doc.cve_id,
|
||
vendor: doc.vendor,
|
||
name: doc.name,
|
||
file_size: doc.file_size,
|
||
mime_type: doc.mime_type,
|
||
}]);
|
||
};
|
||
|
||
const selectedIds = new Set(libraryDocs.map(d => d.id));
|
||
|
||
// ---- Styles ----
|
||
const tabBtnStyle = (active) => ({
|
||
flex: 1,
|
||
padding: '0.45rem 0.5rem',
|
||
background: 'none',
|
||
border: 'none',
|
||
borderBottom: active ? '2px solid #0EA5E9' : '2px solid transparent',
|
||
color: active ? '#0EA5E9' : '#475569',
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.72rem',
|
||
fontWeight: '600',
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.05em',
|
||
transition: 'all 0.12s',
|
||
});
|
||
|
||
const dropZoneStyle = {
|
||
border: '1px dashed rgba(14,165,233,0.25)',
|
||
borderRadius: '0.375rem',
|
||
padding: '1rem',
|
||
textAlign: 'center',
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
background: 'rgba(14,165,233,0.03)',
|
||
transition: 'border-color 0.15s',
|
||
};
|
||
|
||
const searchInputStyle = {
|
||
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 0.45rem 2rem',
|
||
color: '#CBD5E1',
|
||
fontSize: '0.78rem',
|
||
fontFamily: 'monospace',
|
||
outline: 'none',
|
||
};
|
||
|
||
const resultItemStyle = (isSelected) => ({
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '0.5rem',
|
||
padding: '0.4rem 0.5rem',
|
||
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
||
cursor: disabled || isSelected ? 'default' : 'pointer',
|
||
opacity: isSelected ? 0.5 : 1,
|
||
background: isSelected ? 'rgba(14,165,233,0.04)' : 'transparent',
|
||
transition: 'background 0.1s',
|
||
});
|
||
|
||
const badgeStyle = (type) => ({
|
||
display: 'inline-block',
|
||
padding: '0.1rem 0.3rem',
|
||
borderRadius: '0.15rem',
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.58rem',
|
||
fontWeight: '700',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.04em',
|
||
...(type === 'local'
|
||
? { background: 'rgba(14,165,233,0.15)', color: '#0EA5E9', border: '1px solid rgba(14,165,233,0.3)' }
|
||
: { background: 'rgba(245,158,11,0.15)', color: '#F59E0B', border: '1px solid rgba(245,158,11,0.3)' }
|
||
),
|
||
});
|
||
|
||
const totalAttachments = files.length + libraryDocs.length;
|
||
|
||
return (
|
||
<div>
|
||
{/* Mode toggle tabs */}
|
||
<div style={{ display: 'flex', borderBottom: '1px solid rgba(255,255,255,0.06)', marginBottom: '0.625rem' }}>
|
||
<button
|
||
style={tabBtnStyle(mode === 'local')}
|
||
onClick={() => !disabled && setMode('local')}
|
||
disabled={disabled}
|
||
>
|
||
Local Upload
|
||
</button>
|
||
<button
|
||
style={tabBtnStyle(mode === 'library')}
|
||
onClick={() => !disabled && setMode('library')}
|
||
disabled={disabled}
|
||
>
|
||
Library
|
||
</button>
|
||
</div>
|
||
|
||
{/* Local Upload mode */}
|
||
{mode === 'local' && (
|
||
<div>
|
||
<div
|
||
ref={dropRef}
|
||
onDrop={handleDrop}
|
||
onDragOver={handleDragOver}
|
||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||
style={dropZoneStyle}
|
||
>
|
||
<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(',')}
|
||
disabled={disabled}
|
||
/>
|
||
{fileErrors && (
|
||
<div style={{ fontSize: '0.68rem', color: '#EF4444', marginTop: '0.3rem' }}>{fileErrors}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Library mode */}
|
||
{mode === 'library' && (
|
||
<div>
|
||
{/* Search input */}
|
||
<div style={{ position: 'relative', marginBottom: '0.5rem' }}>
|
||
<Search size={14} style={{ position: 'absolute', left: '0.5rem', top: '50%', transform: 'translateY(-50%)', color: '#475569' }} />
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
placeholder="Search documents by name, CVE, or vendor..."
|
||
disabled={disabled}
|
||
style={searchInputStyle}
|
||
/>
|
||
{searching && (
|
||
<Loader size={14} style={{ position: 'absolute', right: '0.5rem', top: '50%', transform: 'translateY(-50%)', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
|
||
)}
|
||
</div>
|
||
|
||
{/* Search results */}
|
||
<div style={{
|
||
maxHeight: '200px',
|
||
overflowY: 'auto',
|
||
border: '1px solid rgba(14,165,233,0.1)',
|
||
borderRadius: '0.25rem',
|
||
background: 'rgba(15,23,42,0.5)',
|
||
}}>
|
||
{searchError && (
|
||
<div style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.72rem', color: '#EF4444', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem' }}>
|
||
<AlertCircle size={13} />
|
||
{searchError}
|
||
</div>
|
||
)}
|
||
{!searchError && !searching && searchResults.length === 0 && (
|
||
<div style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.72rem', color: '#475569' }}>
|
||
No documents found
|
||
</div>
|
||
)}
|
||
{!searchError && searching && searchResults.length === 0 && (
|
||
<div style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.72rem', color: '#64748B', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem' }}>
|
||
<Loader size={13} style={{ animation: 'spin 1s linear infinite' }} />
|
||
Searching...
|
||
</div>
|
||
)}
|
||
{!searchError && searchResults.map(doc => {
|
||
const isSelected = selectedIds.has(doc.id);
|
||
return (
|
||
<div
|
||
key={doc.id}
|
||
style={resultItemStyle(isSelected)}
|
||
onClick={() => !isSelected && selectLibraryDoc(doc)}
|
||
>
|
||
{isSelected ? (
|
||
<Check size={13} style={{ color: '#10B981', flexShrink: 0 }} />
|
||
) : (
|
||
<Database size={13} style={{ color: '#F59E0B', flexShrink: 0 }} />
|
||
)}
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: '0.72rem', color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
|
||
{doc.name}
|
||
</div>
|
||
<div style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', display: 'flex', gap: '0.5rem', marginTop: '0.1rem' }}>
|
||
{doc.cve_id && <span style={{ color: '#0EA5E9' }}>{doc.cve_id}</span>}
|
||
{doc.vendor && <span>{doc.vendor}</span>}
|
||
<span>{formatSize(doc.file_size)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Unified attachment list */}
|
||
{totalAttachments > 0 && (
|
||
<div style={{ marginTop: '0.625rem' }}>
|
||
<div style={{
|
||
fontSize: '0.68rem',
|
||
fontWeight: '600',
|
||
color: '#64748B',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.08em',
|
||
marginBottom: '0.35rem',
|
||
fontFamily: 'monospace',
|
||
}}>
|
||
Attachments ({totalAttachments})
|
||
</div>
|
||
{/* Local files */}
|
||
{files.map((f, i) => (
|
||
<div key={`local-${i}`} style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '0.5rem',
|
||
padding: '0.3rem 0.25rem',
|
||
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
||
}}>
|
||
<span style={badgeStyle('local')}>LOCAL</span>
|
||
<FileText size={13} style={{ color: '#64748B', flexShrink: 0 }} />
|
||
<span style={{
|
||
flex: 1,
|
||
fontSize: '0.72rem',
|
||
color: '#CBD5E1',
|
||
fontFamily: 'monospace',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{f.name}
|
||
</span>
|
||
<span style={{ fontSize: '0.62rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>
|
||
{formatSize(f.size)}
|
||
</span>
|
||
<button
|
||
onClick={() => removeFile(i)}
|
||
disabled={disabled}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
color: '#64748B',
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
padding: '0.15rem',
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
<Trash2 size={12} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
{/* Library docs */}
|
||
{libraryDocs.map(doc => (
|
||
<div key={`lib-${doc.id}`} style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '0.5rem',
|
||
padding: '0.3rem 0.25rem',
|
||
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
||
}}>
|
||
<span style={badgeStyle('library')}>LIBRARY</span>
|
||
<Database size={13} style={{ color: '#F59E0B', flexShrink: 0 }} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontSize: '0.72rem',
|
||
color: '#CBD5E1',
|
||
fontFamily: 'monospace',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{doc.name}
|
||
</div>
|
||
<div style={{ fontSize: '0.6rem', color: '#64748B', fontFamily: 'monospace', display: 'flex', gap: '0.5rem' }}>
|
||
{doc.cve_id && <span style={{ color: '#0EA5E9' }}>{doc.cve_id}</span>}
|
||
{doc.vendor && <span>{doc.vendor}</span>}
|
||
</div>
|
||
</div>
|
||
<span style={{ fontSize: '0.62rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>
|
||
{formatSize(doc.file_size)}
|
||
</span>
|
||
<button
|
||
onClick={() => removeLibraryDoc(doc.id)}
|
||
disabled={disabled}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
color: '#64748B',
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
padding: '0.15rem',
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
<Trash2 size={12} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 [libraryDocs, setLibraryDocs] = useState([]);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [progress, setProgress] = useState({ step: '', current: 0, total: 0 });
|
||
const [errors, setErrors] = useState({});
|
||
const [result, setResult] = useState(null);
|
||
|
||
// Reset form when modal opens
|
||
useEffect(() => {
|
||
if (open) {
|
||
setName('');
|
||
setReason('');
|
||
setDescription('');
|
||
setExpirationDate('');
|
||
setScopeOverride('Authorized');
|
||
setFiles([]);
|
||
setLibraryDocs([]);
|
||
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 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';
|
||
else {
|
||
const maxDate = new Date(today);
|
||
maxDate.setDate(maxDate.getDate() + 120);
|
||
if (exp > maxDate) errs.expirationDate = 'Expiration date cannot be more than 120 days from today';
|
||
}
|
||
}
|
||
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 (libraryDocs.length > 0) {
|
||
formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)));
|
||
}
|
||
|
||
const totalAttachments = files.length + libraryDocs.length;
|
||
if (totalAttachments > 0) {
|
||
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: totalAttachments });
|
||
}
|
||
|
||
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;
|
||
|
||
// ---- 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 style={{
|
||
display: 'inline-block',
|
||
fontSize: '0.6rem',
|
||
fontWeight: '600',
|
||
padding: '0.1rem 0.3rem',
|
||
borderRadius: '0.2rem',
|
||
background: (a.source || 'local') === 'library' ? 'rgba(168,85,247,0.12)' : 'rgba(14,165,233,0.12)',
|
||
color: (a.source || 'local') === 'library' ? '#A855F7' : '#0EA5E9',
|
||
border: `1px solid ${(a.source || 'local') === 'library' ? 'rgba(168,85,247,0.3)' : 'rgba(14,165,233,0.3)'}`,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.04em',
|
||
flexShrink: 0,
|
||
}}>{(a.source || 'local') === 'library' ? 'Library' : 'Local'}</span>
|
||
<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 style={{
|
||
display: 'inline-block',
|
||
fontSize: '0.6rem',
|
||
fontWeight: '600',
|
||
padding: '0.1rem 0.3rem',
|
||
borderRadius: '0.2rem',
|
||
background: (a.source || 'local') === 'library' ? 'rgba(168,85,247,0.12)' : 'rgba(14,165,233,0.12)',
|
||
color: (a.source || 'local') === 'library' ? '#A855F7' : '#0EA5E9',
|
||
border: `1px solid ${(a.source || 'local') === 'library' ? 'rgba(168,85,247,0.3)' : 'rgba(14,165,233,0.3)'}`,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.04em',
|
||
flexShrink: 0,
|
||
}}>{(a.source || 'local') === 'library' ? 'Library' : 'Local'}</span>
|
||
<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)}
|
||
min={(() => { const d = new Date(); d.setDate(d.getDate() + 1); return d.toISOString().split('T')[0]; })()}
|
||
max={(() => { const d = new Date(); d.setDate(d.getDate() + 120); return d.toISOString().split('T')[0]; })()}
|
||
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>
|
||
|
||
{/* Attachments */}
|
||
<div style={sectionStyle}>
|
||
<div style={labelStyle}>Attachments</div>
|
||
<AttachmentSourcePicker
|
||
files={files}
|
||
onFilesChange={setFiles}
|
||
libraryDocs={libraryDocs}
|
||
onLibraryDocsChange={setLibraryDocs}
|
||
disabled={submitting}
|
||
/>
|
||
</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
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// RequeueConfirmDialog — confirmation dialog for re-queuing rejected FP findings
|
||
// ---------------------------------------------------------------------------
|
||
const REQUEUE_WORKFLOW_OPTIONS = [
|
||
{ key: 'FP', label: 'FP', col: '#F59E0B', rgb: '245,158,11' },
|
||
{ key: 'Archer', label: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
|
||
{ key: 'CARD', label: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||
{ key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
|
||
];
|
||
|
||
function RequeueConfirmDialog({ submission, onClose, onSuccess }) {
|
||
const [workflowType, setWorkflowType] = useState('FP');
|
||
const [vendor, setVendor] = useState(() => {
|
||
// Pre-fill vendor from submission's queue items if available
|
||
try {
|
||
const items = JSON.parse(submission.queue_item_ids_json || '[]');
|
||
if (items.length > 0 && submission.vendor) return submission.vendor;
|
||
} catch { /* ignore */ }
|
||
return '';
|
||
});
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
|
||
const needsVendor = workflowType === 'FP' || workflowType === 'Archer';
|
||
const canSubmit = !loading && (!needsVendor || vendor.trim().length > 0);
|
||
|
||
// Count findings
|
||
const findingCount = (() => {
|
||
try {
|
||
const queueIds = JSON.parse(submission.queue_item_ids_json || '[]');
|
||
if (queueIds.length > 0) return queueIds.length;
|
||
const findingIds = JSON.parse(submission.finding_ids_json || '[]');
|
||
return findingIds.length;
|
||
} catch { return 0; }
|
||
})();
|
||
|
||
const handleConfirm = async () => {
|
||
if (!canSubmit) return;
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const body = { workflow_type: workflowType };
|
||
if (needsVendor) body.vendor = vendor.trim();
|
||
|
||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/requeue`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
setError(data.error || 'Re-queue failed.');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
onSuccess(data);
|
||
} catch (err) {
|
||
setError(err.message || 'Network error.');
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return ReactDOM.createPortal(
|
||
<div
|
||
onClick={onClose}
|
||
style={{
|
||
position: 'fixed', inset: 0, zIndex: 10020,
|
||
background: 'rgba(10, 14, 39, 0.92)',
|
||
backdropFilter: 'blur(8px)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
padding: '1rem',
|
||
}}
|
||
>
|
||
<div
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{
|
||
width: '100%', maxWidth: '460px',
|
||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))',
|
||
border: '2px solid rgba(245, 158, 11, 0.4)',
|
||
borderRadius: '0.75rem',
|
||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.7), 0 0 28px rgba(245, 158, 11, 0.12)',
|
||
display: 'flex', flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{/* Top accent line */}
|
||
<div style={{
|
||
height: '2px',
|
||
background: 'linear-gradient(90deg, transparent, #F59E0B, transparent)',
|
||
boxShadow: '0 0 8px rgba(245, 158, 11, 0.4)',
|
||
}} />
|
||
|
||
{/* Header */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '1rem 1.25rem',
|
||
borderBottom: '1px solid rgba(245, 158, 11, 0.15)',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||
<RotateCcw style={{ width: '18px', height: '18px', color: '#F59E0B' }} />
|
||
<span style={{
|
||
fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700',
|
||
color: '#E2E8F0', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||
}}>
|
||
Re-queue Findings
|
||
</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={{ padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||
{/* Finding count info */}
|
||
<div style={{
|
||
background: 'rgba(15, 23, 42, 0.6)',
|
||
border: '1px solid rgba(245, 158, 11, 0.15)',
|
||
borderRadius: '0.5rem',
|
||
padding: '0.75rem 1rem',
|
||
}}>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#CBD5E1' }}>
|
||
<strong style={{ color: '#F59E0B' }}>{findingCount}</strong> finding{findingCount !== 1 ? 's' : ''} will be re-queued from this rejected submission.
|
||
</span>
|
||
</div>
|
||
|
||
{/* Workflow type selector */}
|
||
<div>
|
||
<label style={{
|
||
display: 'block', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||
marginBottom: '0.5rem',
|
||
}}>
|
||
Target Workflow Type
|
||
</label>
|
||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||
{REQUEUE_WORKFLOW_OPTIONS.map(({ key, label, col, rgb }) => {
|
||
const active = workflowType === key;
|
||
return (
|
||
<label
|
||
key={key}
|
||
style={{
|
||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
gap: '0.375rem', padding: '0.45rem 0.5rem', borderRadius: '0.375rem',
|
||
background: active ? `rgba(${rgb}, 0.15)` : 'transparent',
|
||
border: `1.5px solid ${active ? `rgba(${rgb}, 0.5)` : 'rgba(255,255,255,0.08)'}`,
|
||
color: active ? col : '#475569',
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
|
||
cursor: 'pointer', transition: 'all 0.15s',
|
||
}}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="requeue-workflow-type"
|
||
value={key}
|
||
checked={active}
|
||
onChange={() => setWorkflowType(key)}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
<span style={{
|
||
width: '8px', height: '8px', borderRadius: '50%',
|
||
background: active ? col : 'rgba(255,255,255,0.1)',
|
||
boxShadow: active ? `0 0 6px ${col}` : 'none',
|
||
transition: 'all 0.15s',
|
||
}} />
|
||
{label}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Vendor input — conditional */}
|
||
{needsVendor && (
|
||
<div>
|
||
<label style={{
|
||
display: 'block', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||
marginBottom: '0.5rem',
|
||
}}>
|
||
Vendor <span style={{ color: '#EF4444' }}>*</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={vendor}
|
||
onChange={(e) => setVendor(e.target.value)}
|
||
placeholder="e.g. Cisco, Juniper, ADTRAN…"
|
||
maxLength={200}
|
||
style={{
|
||
width: '100%', boxSizing: 'border-box',
|
||
background: 'rgba(30, 41, 59, 0.6)',
|
||
border: '1px solid rgba(245, 158, 11, 0.25)',
|
||
borderRadius: '0.375rem', padding: '0.5rem 0.75rem',
|
||
fontFamily: 'monospace', fontSize: '0.8rem', color: '#E2E8F0',
|
||
outline: 'none', transition: 'border-color 0.2s',
|
||
}}
|
||
onFocus={(e) => { e.target.style.borderColor = '#F59E0B'; }}
|
||
onBlur={(e) => { e.target.style.borderColor = 'rgba(245, 158, 11, 0.25)'; }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Error message */}
|
||
{error && (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'flex-start', gap: '0.5rem',
|
||
padding: '0.625rem 0.75rem',
|
||
background: 'rgba(239, 68, 68, 0.1)',
|
||
border: '1px solid rgba(239, 68, 68, 0.35)',
|
||
borderRadius: '0.375rem',
|
||
}}>
|
||
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#FCA5A5' }}>
|
||
{error}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div style={{
|
||
display: 'flex', justifyContent: 'flex-end', gap: '0.625rem',
|
||
padding: '0.875rem 1.25rem',
|
||
borderTop: '1px solid rgba(245, 158, 11, 0.1)',
|
||
}}>
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
padding: '0.45rem 1rem', background: 'transparent',
|
||
border: '1px solid rgba(255,255,255,0.1)', borderRadius: '0.375rem',
|
||
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em', cursor: 'pointer',
|
||
transition: 'all 0.15s',
|
||
}}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleConfirm}
|
||
disabled={!canSubmit}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.45rem 1.1rem',
|
||
background: canSubmit
|
||
? 'linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(245, 158, 11, 0.1))'
|
||
: 'transparent',
|
||
border: `1.5px solid ${canSubmit ? '#F59E0B' : 'rgba(255,255,255,0.06)'}`,
|
||
borderRadius: '0.375rem',
|
||
color: canSubmit ? '#FBBF24' : '#334155',
|
||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
||
transition: 'all 0.15s',
|
||
}}
|
||
>
|
||
{loading ? (
|
||
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
|
||
) : (
|
||
<RotateCcw style={{ width: '14px', height: '14px' }} />
|
||
)}
|
||
{loading ? 'Re-queuing…' : 'Confirm Re-queue'}
|
||
</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 [libraryDocs, setLibraryDocs] = useState([]);
|
||
const [additionalFindingIds, setAdditionalFindingIds] = useState(new Set());
|
||
const [statusValue, setStatusValue] = useState('');
|
||
const [showRequeueDialog, setShowRequeueDialog] = useState(false);
|
||
|
||
// 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([]);
|
||
setLibraryDocs([]);
|
||
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 && libraryDocs.length === 0) return;
|
||
setSaving(true); setResult(null);
|
||
const formData = new FormData();
|
||
files.forEach(f => formData.append('attachments', f));
|
||
if (libraryDocs.length > 0) {
|
||
formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)));
|
||
}
|
||
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([]);
|
||
setLibraryDocs([]);
|
||
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' };
|
||
|
||
const portal = 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>
|
||
{submission.lifecycle_status === 'rejected' && (
|
||
submission.requeued_at ? (
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#64748B', fontStyle: 'italic' }}>
|
||
Already re-queued
|
||
</span>
|
||
) : (
|
||
<button
|
||
onClick={() => setShowRequeueDialog(true)}
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||
padding: '0.15rem 0.45rem', borderRadius: '0.25rem',
|
||
background: 'rgba(245,158,11,0.08)',
|
||
border: '1px solid rgba(245,158,11,0.3)',
|
||
color: '#F59E0B', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||
cursor: 'pointer', lineHeight: 1.2,
|
||
}}
|
||
>
|
||
<RotateCcw style={{ width: '12px', height: '12px' }} />
|
||
Re-queue Findings
|
||
</button>
|
||
)
|
||
)}
|
||
</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} min={(() => { const d = new Date(); d.setDate(d.getDate() + 1); return d.toISOString().split('T')[0]; })()} max={(() => { const d = new Date(); d.setDate(d.getDate() + 120); return d.toISOString().split('T')[0]; })()} 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>
|
||
{!isApproved && (
|
||
<div style={{ marginTop: '0.75rem' }}>
|
||
<AttachmentSourcePicker
|
||
files={files}
|
||
onFilesChange={setFiles}
|
||
libraryDocs={libraryDocs}
|
||
onLibraryDocsChange={setLibraryDocs}
|
||
disabled={isApproved}
|
||
/>
|
||
{(files.length > 0 || libraryDocs.length > 0) && (
|
||
<button
|
||
onClick={handleUploadAttachments}
|
||
disabled={saving}
|
||
style={{
|
||
marginTop: '0.5rem',
|
||
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 ? 'Uploading…' : `Upload ${files.length + libraryDocs.length} Attachment(s)`}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* History tab */}
|
||
{activeTab === 'history' && (
|
||
<div>
|
||
{/* Ivanti reviewer notes (rework/approval/previous state feedback) */}
|
||
{(() => {
|
||
const safeText = (val) => {
|
||
if (!val) return null;
|
||
if (typeof val === 'string') return val;
|
||
return null;
|
||
};
|
||
const notes = [
|
||
safeText(submission.ivanti_rework_note) && { label: 'Rework Note', text: safeText(submission.ivanti_rework_note) },
|
||
safeText(submission.ivanti_approval_note) && { label: 'Approval Note', text: safeText(submission.ivanti_approval_note) },
|
||
safeText(submission.ivanti_current_state_notes) && { label: 'Current State Notes', text: safeText(submission.ivanti_current_state_notes) },
|
||
safeText(submission.ivanti_previous_state_notes) && { label: 'Previous State Notes', text: safeText(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: '#64748B', textAlign: 'center', padding: '2rem 0' }}>
|
||
{/* ⚠️ CONVENTION: Use lucide-react icons instead of raw HTML entities/emoji */}
|
||
<div style={{ fontSize: '1.5rem', marginBottom: '0.5rem', opacity: 0.4 }}>📋</div>
|
||
No history entries yet.
|
||
<div style={{ fontSize: '0.65rem', color: '#475569', marginTop: '0.35rem' }}>
|
||
Changes to this submission will appear here.
|
||
</div>
|
||
</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
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{portal}
|
||
{showRequeueDialog && <RequeueConfirmDialog
|
||
submission={submission}
|
||
onClose={() => setShowRequeueDialog(false)}
|
||
onSuccess={(data) => {
|
||
setShowRequeueDialog(false);
|
||
setResult({ type: 'success', message: `Re-queued ${data.count} finding(s) as ${data.items[0]?.workflow_type || 'new workflow'}` });
|
||
if (onSuccess) onSuccess();
|
||
}}
|
||
/>}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SelectionToolbar — batch action bar for multi-selected findings
|
||
// ---------------------------------------------------------------------------
|
||
function SelectionToolbar({ count, workflowType, vendor, submitting, error, onWorkflowChange, onVendorChange, onSubmit, onClear }) {
|
||
const isCard = workflowType === 'CARD' || workflowType === 'GRANITE' || workflowType === 'DECOM';
|
||
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' },
|
||
{ type: 'GRANITE', color: '#A1887F', rgb: '161,136,127' },
|
||
{ type: 'DECOM', color: '#EF4444', rgb: '239,68,68' },
|
||
{ type: 'Remediate', color: '#A855F7', rgb: '168,85,247' },
|
||
].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>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// RowVisibilityManager — popover for viewing and restoring hidden rows
|
||
// ---------------------------------------------------------------------------
|
||
function RowVisibilityManager({ hiddenRowIds, findings, onRestore, onRestoreAll }) {
|
||
const [open, setOpen] = useState(false);
|
||
const panelRef = useRef(null);
|
||
const btnRef = useRef(null);
|
||
|
||
// Close on outside click (same pattern as ColumnManager)
|
||
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 hiddenCount = hiddenRowIds.size;
|
||
|
||
// Build list of hidden findings with title lookup
|
||
const hiddenEntries = useMemo(() => {
|
||
const ids = [...hiddenRowIds];
|
||
return ids.map(id => {
|
||
const finding = findings.find(f => String(f.id) === String(id));
|
||
return { id, title: finding ? finding.title : null };
|
||
});
|
||
}, [hiddenRowIds, findings]);
|
||
|
||
return (
|
||
<div style={{ position: 'relative' }}>
|
||
<button
|
||
ref={btnRef}
|
||
onClick={() => setOpen(p => !p)}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.35rem 0.75rem',
|
||
background: open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.08)',
|
||
border: `1px solid rgba(14,165,233,${open ? '0.5' : '0.2'})`,
|
||
borderRadius: '0.375rem',
|
||
color: '#94a3b8', cursor: 'pointer',
|
||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
}}
|
||
>
|
||
<EyeOff style={{ width: '13px', height: '13px' }} />
|
||
Hidden ({hiddenCount})
|
||
</button>
|
||
|
||
{open && (
|
||
<div
|
||
ref={panelRef}
|
||
style={{
|
||
position: 'absolute', top: 'calc(100% + 8px)', right: 0,
|
||
width: '300px', zIndex: 100,
|
||
background: 'linear-gradient(135deg, rgba(15,23,42,0.98), rgba(30,41,59,0.98))',
|
||
border: '1px solid rgba(14,165,233,0.25)',
|
||
borderRadius: '8px',
|
||
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
|
||
padding: '0.5rem',
|
||
maxHeight: '320px',
|
||
overflowY: 'auto',
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<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',
|
||
}}>
|
||
Hidden Rows
|
||
</div>
|
||
|
||
{hiddenCount === 0 ? (
|
||
<div style={{
|
||
padding: '1rem 0.5rem',
|
||
textAlign: 'center',
|
||
fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569',
|
||
}}>
|
||
No rows hidden
|
||
</div>
|
||
) : (
|
||
<>
|
||
{hiddenEntries.map(entry => (
|
||
<div
|
||
key={entry.id}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||
padding: '0.4rem 0.5rem', borderRadius: '0.25rem',
|
||
transition: 'background 0.1s',
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
|
||
color: '#CBD5E1', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||
}}>
|
||
{entry.id}
|
||
</div>
|
||
{entry.title && (
|
||
<div style={{
|
||
fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B',
|
||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||
marginTop: '1px',
|
||
}}>
|
||
{entry.title}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={() => onRestore(entry.id)}
|
||
title="Restore row"
|
||
style={{
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
padding: '2px', color: '#334155', lineHeight: 1,
|
||
transition: 'color 0.15s',
|
||
}}
|
||
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||
onMouseLeave={e => { e.currentTarget.style.color = '#334155'; }}
|
||
>
|
||
<Eye style={{ width: '14px', height: '14px' }} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
|
||
{/* Restore All button */}
|
||
<div style={{
|
||
borderTop: '1px solid rgba(255,255,255,0.05)',
|
||
marginTop: '0.375rem',
|
||
paddingTop: '0.375rem',
|
||
}}>
|
||
<button
|
||
onClick={onRestoreAll}
|
||
style={{
|
||
width: '100%',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem',
|
||
padding: '0.4rem 0.5rem',
|
||
background: 'rgba(14,165,233,0.08)',
|
||
border: '1px solid rgba(14,165,233,0.2)',
|
||
borderRadius: '0.25rem',
|
||
color: '#0EA5E9', cursor: 'pointer',
|
||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
transition: 'all 0.15s',
|
||
}}
|
||
>
|
||
<RotateCcw style={{ width: '12px', height: '12px' }} />
|
||
Restore All
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// BulkHideToolbar — appears when rows are selected for bulk hiding
|
||
// ---------------------------------------------------------------------------
|
||
function BulkHideToolbar({ count, onHide, onClear, onAtlasBulk, canWrite }) {
|
||
return (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||
padding: '0.5rem 1rem',
|
||
background: 'linear-gradient(135deg, rgba(15,23,42,0.95), rgba(30,41,59,0.95))',
|
||
border: '1px solid rgba(14,165,233,0.3)',
|
||
borderRadius: '6px',
|
||
marginBottom: '0.5rem',
|
||
}}>
|
||
{/* Count label */}
|
||
<span style={{
|
||
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600', color: '#e2e8f0',
|
||
}}>
|
||
{count} row{count !== 1 ? 's' : ''} selected
|
||
</span>
|
||
|
||
{/* Hide Selected button */}
|
||
<button
|
||
onClick={onHide}
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.3rem 0.625rem',
|
||
background: 'rgba(14,165,233,0.12)',
|
||
border: '1px solid rgba(79,195,247,0.35)',
|
||
borderRadius: '0.25rem',
|
||
color: '#4fc3f7',
|
||
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.15s',
|
||
}}
|
||
>
|
||
<EyeOff style={{ width: '12px', height: '12px' }} />
|
||
Hide Selected
|
||
</button>
|
||
|
||
{/* Bulk Atlas Action Plan button */}
|
||
{canWrite && onAtlasBulk && (
|
||
<button
|
||
onClick={onAtlasBulk}
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.3rem 0.625rem',
|
||
background: 'rgba(14,165,233,0.12)',
|
||
border: '1px solid rgba(79,195,247,0.35)',
|
||
borderRadius: '0.25rem',
|
||
color: '#4fc3f7',
|
||
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.15s',
|
||
}}
|
||
>
|
||
<Database style={{ width: '12px', height: '12px' }} />
|
||
Atlas Action Plan
|
||
</button>
|
||
)}
|
||
|
||
{/* Clear button */}
|
||
<button
|
||
onClick={onClear}
|
||
style={{
|
||
background: 'none', border: 'none',
|
||
fontFamily: 'monospace', fontSize: '0.7rem', color: '#64748B',
|
||
cursor: 'pointer', padding: '0.3rem 0.375rem',
|
||
transition: 'color 0.15s',
|
||
}}
|
||
>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// BulkAtlasModal — modal for creating action plans on multiple hosts at once
|
||
// ---------------------------------------------------------------------------
|
||
const BULK_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||
const BULK_PLAN_TYPE_COLORS = {
|
||
remediation: '#0EA5E9', decommission: '#EF4444', false_positive: '#F59E0B',
|
||
risk_acceptance: '#A855F7', scan_exclusion: '#64748B',
|
||
};
|
||
const NEEDS_QUALYS = new Set(['remediation', 'false_positive', 'risk_acceptance']);
|
||
|
||
function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||
const [planType, setPlanType] = useState('risk_acceptance');
|
||
const [commitDate, setCommitDate] = useState('');
|
||
const [jiraVnr, setJiraVnr] = useState('');
|
||
const [archerExc, setArcherExc] = useState('');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
const [result, setResult] = useState(null);
|
||
const [typeOpen, setTypeOpen] = useState(false);
|
||
const typeRef = useRef(null);
|
||
|
||
// Vulnerability loading state
|
||
const [vulnsLoading, setVulnsLoading] = useState(false);
|
||
const [vulnsError, setVulnsError] = useState(null);
|
||
const [availableQualys, setAvailableQualys] = useState([]);
|
||
const [selectedQualys, setSelectedQualys] = useState(new Set());
|
||
|
||
// Close type dropdown on outside click
|
||
useEffect(() => {
|
||
if (!typeOpen) return;
|
||
const handler = (e) => { if (typeRef.current && !typeRef.current.contains(e.target)) setTypeOpen(false); };
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [typeOpen]);
|
||
|
||
// Deduplicate host IDs from selected findings
|
||
const hostEntries = useMemo(() => {
|
||
const seen = new Map();
|
||
for (const f of selectedFindings) {
|
||
if (f.hostId && !seen.has(f.hostId)) {
|
||
seen.set(f.hostId, {
|
||
hostId: f.hostId,
|
||
hostName: f.overrides?.hostName || f.hostName || f.ipAddress || String(f.hostId),
|
||
findingId: f.id ? Number(f.id) : null,
|
||
});
|
||
}
|
||
}
|
||
return [...seen.values()];
|
||
}, [selectedFindings]);
|
||
|
||
const hostIds = useMemo(() => hostEntries.map(h => h.hostId), [hostEntries]);
|
||
|
||
// Fetch vulnerabilities from Atlas when modal opens
|
||
useEffect(() => {
|
||
if (hostIds.length === 0) return;
|
||
let cancelled = false;
|
||
const fetchVulns = async () => {
|
||
setVulnsLoading(true);
|
||
setVulnsError(null);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/atlas/hosts/vulnerabilities`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ host_ids: hostIds }),
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error || `Failed to fetch vulnerabilities (${res.status})`);
|
||
}
|
||
const data = await res.json();
|
||
if (cancelled) return;
|
||
|
||
// Parse response — Atlas returns { "host_id": [ { qualys_id, title, ... }, ... ], ... }
|
||
const qualysMap = new Map();
|
||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||
for (const [, vulnList] of Object.entries(data)) {
|
||
if (!Array.isArray(vulnList)) continue;
|
||
for (const vuln of vulnList) {
|
||
const qid = vuln.qualys_id || vuln.sourceId;
|
||
if (!qid) continue;
|
||
if (!qualysMap.has(qid)) {
|
||
qualysMap.set(qid, {
|
||
qualys_id: qid,
|
||
title: vuln.title || qid,
|
||
count: 1,
|
||
});
|
||
} else {
|
||
qualysMap.get(qid).count++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const sorted = [...qualysMap.values()].sort((a, b) => b.count - a.count);
|
||
setAvailableQualys(sorted);
|
||
setSelectedQualys(new Set(sorted.map(q => q.qualys_id)));
|
||
} catch (err) {
|
||
if (!cancelled) setVulnsError(err.message);
|
||
} finally {
|
||
if (!cancelled) setVulnsLoading(false);
|
||
}
|
||
};
|
||
fetchVulns();
|
||
return () => { cancelled = true; };
|
||
}, [hostIds]);
|
||
|
||
const toggleQualys = (qid) => {
|
||
setSelectedQualys(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(qid)) next.delete(qid); else next.add(qid);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const toggleAllQualys = () => {
|
||
if (selectedQualys.size === availableQualys.length) {
|
||
setSelectedQualys(new Set());
|
||
} else {
|
||
setSelectedQualys(new Set(availableQualys.map(q => q.qualys_id)));
|
||
}
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!commitDate) { setError('Commit date is required'); return; }
|
||
if (hostIds.length === 0) { setError('No valid host IDs in selection'); return; }
|
||
const needsQualys = NEEDS_QUALYS.has(planType);
|
||
if (needsQualys && selectedQualys.size === 0 && availableQualys.length > 0) {
|
||
setError(`At least one Qualys ID is required for ${planType.replace(/_/g, ' ')} plans`);
|
||
return;
|
||
}
|
||
|
||
setSubmitting(true);
|
||
setError(null);
|
||
try {
|
||
// If qualys IDs are selected, iterate per-qualys; otherwise send one request without qualys_id
|
||
const qualysIds = (needsQualys && selectedQualys.size > 0) ? [...selectedQualys] : [null];
|
||
const results = [];
|
||
|
||
for (const qid of qualysIds) {
|
||
const body = { host_ids: hostIds, plan_type: planType, commit_date: commitDate };
|
||
if (qid) body.qualys_id = qid;
|
||
// When no qualys_id is available, include the first finding ID per host
|
||
// so Atlas can associate the plan with a specific vulnerability
|
||
if (!qid && needsQualys) {
|
||
const firstWithFinding = hostEntries.find(h => h.findingId);
|
||
if (firstWithFinding) body.active_host_findings_id = firstWithFinding.findingId;
|
||
}
|
||
if (jiraVnr.trim()) body.jira_vnr = jiraVnr.trim();
|
||
if (archerExc.trim()) body.archer_exc = archerExc.trim();
|
||
|
||
const res = await fetch(`${API_BASE}/atlas/hosts/bulk-action-plans`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
results.push({ qualys_id: qid, success: false, error: data.error || data.detail || `Failed (${res.status})` });
|
||
} else {
|
||
results.push({ qualys_id: qid, success: true, data });
|
||
}
|
||
}
|
||
|
||
const succeeded = results.filter(r => r.success).length;
|
||
const failed = results.filter(r => !r.success);
|
||
|
||
if (failed.length > 0 && succeeded === 0) {
|
||
throw new Error(failed[0].error);
|
||
}
|
||
|
||
setResult({ succeeded, failed: failed.length, total: results.length, details: results });
|
||
if (onSuccess) onSuccess();
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const inputSt = {
|
||
width: '100%', boxSizing: 'border-box',
|
||
background: 'rgba(14,165,233,0.06)', border: '1px solid rgba(14,165,233,0.2)',
|
||
borderRadius: '0.375rem', color: '#E2E8F0', padding: '0.5rem 0.625rem',
|
||
fontSize: '0.78rem', fontFamily: "'JetBrains Mono', monospace", outline: 'none',
|
||
};
|
||
const labelSt = {
|
||
display: 'block', fontSize: '0.68rem', fontFamily: "'JetBrains Mono', monospace",
|
||
color: '#94A3B8', marginBottom: '0.3rem', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
};
|
||
|
||
return ReactDOM.createPortal(
|
||
<>
|
||
{/* Backdrop */}
|
||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 60 }} />
|
||
|
||
{/* Modal */}
|
||
<div style={{
|
||
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
||
width: '520px', maxHeight: '80vh', overflowY: 'auto',
|
||
background: '#0A1220',
|
||
border: '1px solid rgba(14,165,233,0.25)',
|
||
borderRadius: '0.5rem',
|
||
boxShadow: '0 16px 48px rgba(0,0,0,0.6)',
|
||
zIndex: 61,
|
||
fontFamily: "'JetBrains Mono', monospace",
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{
|
||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||
padding: '1rem 1.25rem',
|
||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
<Database style={{ width: 16, height: 16, color: '#0EA5E9' }} />
|
||
<span style={{ fontSize: '0.85rem', fontWeight: 700, color: '#E2E8F0' }}>
|
||
Bulk Atlas Action Plan
|
||
</span>
|
||
</div>
|
||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '0.25rem' }}>
|
||
<X style={{ width: 18, height: 18 }} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Success state */}
|
||
{result ? (
|
||
<div style={{ padding: '1.5rem 1.25rem', textAlign: 'center' }}>
|
||
<Check style={{ width: 32, height: 32, color: '#10B981', margin: '0 auto 0.75rem' }} />
|
||
<div style={{ fontSize: '0.85rem', color: '#E2E8F0', fontWeight: 600, marginBottom: '0.5rem' }}>
|
||
Action plans created
|
||
</div>
|
||
<div style={{ fontSize: '0.72rem', color: '#94A3B8', marginBottom: '0.5rem' }}>
|
||
{hostIds.length} host{hostIds.length !== 1 ? 's' : ''} — {planType.replace(/_/g, ' ')}
|
||
</div>
|
||
{result.total > 1 && (
|
||
<div style={{ fontSize: '0.72rem', color: '#94A3B8', marginBottom: '0.5rem' }}>
|
||
{result.succeeded} of {result.total} Qualys ID{result.total !== 1 ? 's' : ''} succeeded
|
||
{result.failed > 0 && <span style={{ color: '#F87171' }}> — {result.failed} failed</span>}
|
||
</div>
|
||
)}
|
||
{result.details?.filter(d => !d.success).map((d, i) => (
|
||
<div key={i} style={{ fontSize: '0.68rem', color: '#F87171', marginTop: '0.25rem' }}>
|
||
{d.qualys_id}: {d.error}
|
||
</div>
|
||
))}
|
||
<button onClick={onClose} style={{
|
||
marginTop: '1rem',
|
||
padding: '0.5rem 1.25rem',
|
||
background: 'rgba(14,165,233,0.15)', border: '1px solid #0EA5E9',
|
||
borderRadius: '0.375rem', color: '#38BDF8',
|
||
fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
|
||
}}>
|
||
Close
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div style={{ padding: '1rem 1.25rem' }}>
|
||
{/* Host summary */}
|
||
<div style={{
|
||
marginBottom: '1rem', padding: '0.625rem 0.75rem',
|
||
background: 'rgba(14,165,233,0.06)',
|
||
border: '1px solid rgba(14,165,233,0.15)',
|
||
borderRadius: '0.375rem',
|
||
}}>
|
||
<div style={{ fontSize: '0.68rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.4rem' }}>
|
||
{hostEntries.length} unique host{hostEntries.length !== 1 ? 's' : ''} from {selectedFindings.length} selected finding{selectedFindings.length !== 1 ? 's' : ''}
|
||
</div>
|
||
<div style={{ maxHeight: '100px', overflowY: 'auto', fontSize: '0.72rem', color: '#CBD5E1', lineHeight: 1.6 }}>
|
||
{hostEntries.map(h => (
|
||
<div key={h.hostId} style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem' }}>
|
||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{h.hostName}</span>
|
||
<span style={{ color: '#475569', flexShrink: 0 }}>{h.hostId}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Plan type dropdown */}
|
||
<div style={{ marginBottom: '0.75rem' }}>
|
||
<label style={labelSt}>Plan Type</label>
|
||
<div ref={typeRef} style={{ position: 'relative' }}>
|
||
<button type="button" onClick={() => setTypeOpen(!typeOpen)} style={{
|
||
...inputSt, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
cursor: 'pointer', textAlign: 'left',
|
||
borderColor: typeOpen ? 'rgba(14,165,233,0.5)' : 'rgba(14,165,233,0.2)',
|
||
}}>
|
||
<span style={{ color: BULK_PLAN_TYPE_COLORS[planType], fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>
|
||
{planType.replace(/_/g, ' ')}
|
||
</span>
|
||
<ChevronDown style={{ width: 14, height: 14, color: '#475569', transform: typeOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||
</button>
|
||
{typeOpen && (
|
||
<div style={{
|
||
position: 'absolute', top: '100%', left: 0, right: 0, marginTop: '4px',
|
||
background: '#0F1A2E', border: '1px solid rgba(14,165,233,0.25)',
|
||
borderRadius: '0.375rem', boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||
zIndex: 65, overflow: 'hidden',
|
||
}}>
|
||
{BULK_PLAN_TYPES.map(t => (
|
||
<div key={t} onClick={() => { setPlanType(t); setTypeOpen(false); }} style={{
|
||
padding: '0.5rem 0.625rem', cursor: 'pointer',
|
||
background: t === planType ? 'rgba(14,165,233,0.12)' : 'transparent',
|
||
color: BULK_PLAN_TYPE_COLORS[t], fontSize: '0.78rem',
|
||
fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em',
|
||
}}
|
||
onMouseEnter={e => { if (t !== planType) e.currentTarget.style.background = 'rgba(14,165,233,0.06)'; }}
|
||
onMouseLeave={e => { if (t !== planType) e.currentTarget.style.background = 'transparent'; }}
|
||
>
|
||
{t.replace(/_/g, ' ')}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Commit date */}
|
||
<div style={{ marginBottom: '0.75rem' }}>
|
||
<label style={labelSt}>Commit Date</label>
|
||
<input type="date" value={commitDate} onChange={e => setCommitDate(e.target.value)}
|
||
style={{ ...inputSt, colorScheme: 'dark' }} />
|
||
</div>
|
||
|
||
{/* Optional fields — shown based on plan type */}
|
||
{/* Qualys ID multi-select — shown for plan types that require it */}
|
||
{NEEDS_QUALYS.has(planType) && (
|
||
<div style={{ marginBottom: '0.75rem' }}>
|
||
<label style={labelSt}>
|
||
Qualys IDs
|
||
<span style={{ color: '#475569', textTransform: 'none', marginLeft: '0.3rem' }}>
|
||
({selectedQualys.size} of {availableQualys.length} selected)
|
||
</span>
|
||
</label>
|
||
|
||
{vulnsLoading && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.5rem 0', color: '#475569', fontSize: '0.72rem' }}>
|
||
<Loader style={{ width: 12, height: 12, animation: 'spin 1s linear infinite' }} />
|
||
Loading vulnerabilities from Atlas...
|
||
</div>
|
||
)}
|
||
|
||
{vulnsError && (
|
||
<div style={{
|
||
padding: '0.5rem 0.75rem',
|
||
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
|
||
borderRadius: '0.375rem', color: '#F87171', fontSize: '0.72rem',
|
||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||
}}>
|
||
<AlertCircle style={{ width: 12, height: 12, flexShrink: 0 }} />{vulnsError}
|
||
</div>
|
||
)}
|
||
|
||
{!vulnsLoading && !vulnsError && availableQualys.length === 0 && (
|
||
<div style={{ color: '#475569', fontSize: '0.72rem', fontStyle: 'italic', padding: '0.5rem 0' }}>
|
||
No vulnerabilities found in Atlas for these hosts — Qualys ID will be omitted
|
||
</div>
|
||
)}
|
||
|
||
{!vulnsLoading && availableQualys.length > 0 && (
|
||
<div style={{
|
||
background: 'rgba(14,165,233,0.04)',
|
||
border: '1px solid rgba(14,165,233,0.15)',
|
||
borderRadius: '0.375rem',
|
||
maxHeight: '180px', overflowY: 'auto',
|
||
}}>
|
||
<div
|
||
onClick={toggleAllQualys}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||
padding: '0.45rem 0.625rem',
|
||
borderBottom: '1px solid rgba(14,165,233,0.1)',
|
||
cursor: 'pointer', fontSize: '0.72rem', color: '#94A3B8',
|
||
}}
|
||
>
|
||
<input type="checkbox" readOnly
|
||
checked={selectedQualys.size === availableQualys.length && availableQualys.length > 0}
|
||
style={{ accentColor: '#0EA5E9', cursor: 'pointer' }}
|
||
/>
|
||
<span style={{ fontWeight: 600 }}>Select All</span>
|
||
</div>
|
||
|
||
{availableQualys.map(q => (
|
||
<div
|
||
key={q.qualys_id}
|
||
onClick={() => toggleQualys(q.qualys_id)}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||
padding: '0.4rem 0.625rem',
|
||
cursor: 'pointer', fontSize: '0.72rem',
|
||
background: selectedQualys.has(q.qualys_id) ? 'rgba(14,165,233,0.08)' : 'transparent',
|
||
transition: 'background 0.1s',
|
||
}}
|
||
onMouseEnter={e => { if (!selectedQualys.has(q.qualys_id)) e.currentTarget.style.background = 'rgba(14,165,233,0.04)'; }}
|
||
onMouseLeave={e => { if (!selectedQualys.has(q.qualys_id)) e.currentTarget.style.background = 'transparent'; }}
|
||
>
|
||
<input type="checkbox" readOnly
|
||
checked={selectedQualys.has(q.qualys_id)}
|
||
style={{ accentColor: '#0EA5E9', cursor: 'pointer', flexShrink: 0 }}
|
||
/>
|
||
<div style={{ minWidth: 0, flex: 1 }}>
|
||
<span style={{ color: '#E2E8F0', fontWeight: 600 }}>{q.qualys_id}</span>
|
||
<span style={{ color: '#475569', marginLeft: '0.4rem' }}>
|
||
({q.count} host{q.count !== 1 ? 's' : ''})
|
||
</span>
|
||
<div style={{ color: '#64748B', fontSize: '0.65rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{q.title}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{planType === 'false_positive' && (
|
||
<div style={{ marginBottom: '0.75rem' }}>
|
||
<label style={labelSt}>Jira VNR <span style={{ color: '#475569', textTransform: 'none' }}>(optional)</span></label>
|
||
<input value={jiraVnr} onChange={e => setJiraVnr(e.target.value)}
|
||
placeholder="VNR-67890" style={inputSt} />
|
||
</div>
|
||
)}
|
||
{(planType === 'risk_acceptance' || planType === 'scan_exclusion') && (
|
||
<div style={{ marginBottom: '0.75rem' }}>
|
||
<label style={labelSt}>Archer EXC <span style={{ color: '#475569', textTransform: 'none' }}>(optional)</span></label>
|
||
<input value={archerExc} onChange={e => setArcherExc(e.target.value)}
|
||
placeholder="EXC-54321" style={inputSt} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Error */}
|
||
{error && (
|
||
<div style={{
|
||
marginBottom: '0.75rem', padding: '0.5rem 0.75rem',
|
||
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
|
||
borderRadius: '0.375rem', color: '#F87171', fontSize: '0.75rem',
|
||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||
}}>
|
||
<AlertCircle style={{ width: 14, height: 14, flexShrink: 0 }} />{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Submit */}
|
||
<button onClick={handleSubmit} disabled={submitting || vulnsLoading} style={{
|
||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
|
||
padding: '0.6rem 1rem',
|
||
background: (submitting || vulnsLoading) ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.15)',
|
||
border: '1px solid #0EA5E9', borderRadius: '0.375rem',
|
||
color: (submitting || vulnsLoading) ? '#475569' : '#38BDF8',
|
||
fontSize: '0.78rem', fontWeight: 600, cursor: (submitting || vulnsLoading) ? 'not-allowed' : 'pointer',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
}}>
|
||
{submitting ? <Loader style={{ width: 14, height: 14, animation: 'spin 1s linear infinite' }} /> : <Database style={{ width: 14, height: 14 }} />}
|
||
{submitting ? 'Creating...' : `Create Plans for ${hostEntries.length} Host${hostEntries.length !== 1 ? 's' : ''}${NEEDS_QUALYS.has(planType) && selectedQualys.size > 0 ? ` × ${selectedQualys.size} QID${selectedQualys.size !== 1 ? 's' : ''}` : ''}`}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>,
|
||
document.body
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main ReportingPage
|
||
// ---------------------------------------------------------------------------
|
||
export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||
const { canWrite, getActiveTeamsParam, hasTeams, isAdmin, adminScope } = 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);
|
||
|
||
// CARD owner tooltip state & refs
|
||
const [cardTooltipIp, setCardTooltipIp] = useState(null);
|
||
const [cardTooltipAnchorRect, setCardTooltipAnchorRect] = useState(null);
|
||
const [cardTooltipHostId, setCardTooltipHostId] = useState(null);
|
||
const cardTooltipCacheRef = useRef(new Map());
|
||
const cardHoverTimerRef = useRef(null);
|
||
|
||
// Atlas action plan state
|
||
const [metricsTab, setMetricsTab] = useState('ivanti');
|
||
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
|
||
const [atlasSyncing, setAtlasSyncing] = useState(false);
|
||
const [atlasError, setAtlasError] = useState(null);
|
||
const [atlasPanelOpen, setAtlasPanelOpen] = useState(false);
|
||
const [atlasSelectedHostId, setAtlasSelectedHostId] = useState(null);
|
||
const [atlasSelectedHostName, setAtlasSelectedHostName] = useState(null);
|
||
const [atlasSelectedFindingId, setAtlasSelectedFindingId] = useState(null);
|
||
const [bulkAtlasOpen, setBulkAtlasOpen] = useState(false);
|
||
|
||
// Atlas metrics state (for Atlas Coverage tab donut charts)
|
||
const [atlasMetrics, setAtlasMetrics] = useState(null);
|
||
const [atlasMetricsLoading, setAtlasMetricsLoading] = useState(false);
|
||
const [atlasMetricsError, setAtlasMetricsError] = useState(null);
|
||
|
||
// CARD API state — session-level caching for teams list
|
||
const [cardConfigured, setCardConfigured] = useState(false);
|
||
const [cardTeams, setCardTeams] = useState([]);
|
||
|
||
// Group-by-host toggle state
|
||
const [groupByHost, setGroupByHost] = useState(false);
|
||
const [expandedHosts, setExpandedHosts] = useState(new Set());
|
||
|
||
const updateColumns = useCallback((newOrder) => {
|
||
setColumnOrder(newOrder);
|
||
saveColumnOrder(newOrder);
|
||
}, []);
|
||
|
||
// Hidden row state (row visibility feature)
|
||
const [hiddenRowIds, setHiddenRowIds] = useState(loadHiddenRows);
|
||
|
||
const hideRow = useCallback((findingId) => {
|
||
setHiddenRowIds(prev => {
|
||
const next = new Set(prev);
|
||
next.add(String(findingId));
|
||
saveHiddenRows(next);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const restoreRow = useCallback((findingId) => {
|
||
setHiddenRowIds(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(String(findingId));
|
||
saveHiddenRows(next);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const restoreAllRows = useCallback(() => {
|
||
setHiddenRowIds(new Set());
|
||
saveHiddenRows(new Set());
|
||
}, []);
|
||
|
||
// Selection state (row visibility feature — bulk hide)
|
||
const [selectedRowIds, setSelectedRowIds] = useState(new Set());
|
||
|
||
const toggleRowSelection = useCallback((findingId) => {
|
||
setSelectedRowIds(prev => {
|
||
const next = new Set(prev);
|
||
const id = String(findingId);
|
||
if (next.has(id)) next.delete(id); else next.add(id);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const hideSelectedRows = useCallback(() => {
|
||
setHiddenRowIds(prev => {
|
||
const next = new Set(prev);
|
||
selectedRowIds.forEach(id => next.add(String(id)));
|
||
saveHiddenRows(next);
|
||
return next;
|
||
});
|
||
setSelectedRowIds(new Set());
|
||
}, [selectedRowIds]);
|
||
|
||
// 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);
|
||
}, []);
|
||
|
||
// CARD owner tooltip hover handlers (with hover bridge — stays open when hovering tooltip)
|
||
const handleIpMouseEnter = useCallback((ip, e, hostId) => {
|
||
if (!ip) return;
|
||
clearTimeout(cardHoverTimerRef.current);
|
||
cardHoverTimerRef.current = setTimeout(() => {
|
||
setCardTooltipIp(ip);
|
||
setCardTooltipAnchorRect(e.target.getBoundingClientRect());
|
||
setCardTooltipHostId(hostId || null);
|
||
}, 400);
|
||
}, []);
|
||
|
||
const handleIpMouseLeave = useCallback(() => {
|
||
clearTimeout(cardHoverTimerRef.current);
|
||
// Delay hiding to allow mouse to move into tooltip
|
||
cardHoverTimerRef.current = setTimeout(() => {
|
||
setCardTooltipIp(null);
|
||
setCardTooltipAnchorRect(null);
|
||
setCardTooltipHostId(null);
|
||
}, 150);
|
||
}, []);
|
||
|
||
const handleCardTooltipEnter = useCallback(() => {
|
||
// Mouse entered tooltip — cancel the hide timer
|
||
clearTimeout(cardHoverTimerRef.current);
|
||
}, []);
|
||
|
||
const handleCardTooltipLeave = useCallback(() => {
|
||
// Mouse left tooltip — hide it
|
||
clearTimeout(cardHoverTimerRef.current);
|
||
setCardTooltipIp(null);
|
||
setCardTooltipAnchorRect(null);
|
||
setCardTooltipHostId(null);
|
||
}, []);
|
||
|
||
// CARD action — open CardActionModal from tooltip
|
||
const [cardActionIp, setCardActionIp] = useState(null);
|
||
const [cardActionData, setCardActionData] = useState(null);
|
||
|
||
const handleCardAction = useCallback((ip, data) => {
|
||
setCardActionIp(ip);
|
||
setCardActionData(data);
|
||
// Close the tooltip
|
||
setCardTooltipIp(null);
|
||
setCardTooltipAnchorRect(null);
|
||
setCardTooltipHostId(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 {
|
||
// Fetch counts from server — Postgres provides per-BU open+closed counts
|
||
const teamsParam = getActiveTeamsParam();
|
||
const url = teamsParam
|
||
? `${API_BASE}/ivanti/findings/counts?teams=${encodeURIComponent(teamsParam)}`
|
||
: `${API_BASE}/ivanti/findings/counts`;
|
||
const res = await fetch(url, { 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 teamsParam = getActiveTeamsParam();
|
||
const url = teamsParam
|
||
? `${API_BASE}/ivanti/findings/fp-workflow-counts?teams=${encodeURIComponent(teamsParam)}`
|
||
: `${API_BASE}/ivanti/findings/fp-workflow-counts`;
|
||
const res = await fetch(url, { 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 fetchAtlasStatus = useCallback(async () => {
|
||
try {
|
||
const teamsParam = getActiveTeamsParam();
|
||
const url = teamsParam
|
||
? `${API_BASE}/atlas/status?teams=${encodeURIComponent(teamsParam)}`
|
||
: `${API_BASE}/atlas/status`;
|
||
const res = await fetch(url, { credentials: 'include' });
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const map = new Map();
|
||
data.forEach(row => map.set(row.host_id, row));
|
||
setAtlasStatusMap(map);
|
||
}
|
||
} catch (err) {
|
||
console.error('[Atlas] Failed to fetch status:', err.message);
|
||
}
|
||
}, []);
|
||
|
||
const fetchAtlasMetrics = useCallback(async () => {
|
||
setAtlasMetricsLoading(true);
|
||
setAtlasMetricsError(null);
|
||
try {
|
||
const teamsParam = getActiveTeamsParam();
|
||
const url = teamsParam
|
||
? `${API_BASE}/atlas/metrics?teams=${encodeURIComponent(teamsParam)}`
|
||
: `${API_BASE}/atlas/metrics`;
|
||
const res = await fetch(url, { credentials: 'include' });
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setAtlasMetrics(data);
|
||
} else {
|
||
const err = await res.json().catch(() => ({}));
|
||
setAtlasMetricsError(err.error || 'Failed to fetch Atlas metrics');
|
||
}
|
||
} catch (err) {
|
||
setAtlasMetricsError(err.message);
|
||
} finally {
|
||
setAtlasMetricsLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// CARD API — fetch status and teams (session-level caching)
|
||
const cardTeamsFetchedRef = useRef(false);
|
||
const cardTeamsRetryRef = useRef(0);
|
||
const fetchCardStatus = useCallback(async () => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/card/status`, { credentials: 'include' });
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setCardConfigured(data.configured === true);
|
||
if (data.configured && !cardTeamsFetchedRef.current) {
|
||
const teamsRes = await fetch(`${API_BASE}/card/teams`, { credentials: 'include' });
|
||
if (teamsRes.ok) {
|
||
const teamsData = await teamsRes.json();
|
||
const teams = Array.isArray(teamsData)
|
||
? teamsData.map(t => t.card_team_name || t._id).filter(Boolean).sort()
|
||
: [];
|
||
if (teams.length > 0) {
|
||
setCardTeams(teams);
|
||
cardTeamsFetchedRef.current = true;
|
||
}
|
||
} else if (cardTeamsRetryRef.current < 3) {
|
||
// Retry silently after a delay (CARD teams endpoint can be slow)
|
||
cardTeamsRetryRef.current += 1;
|
||
setTimeout(() => fetchCardStatus(), 15000 * cardTeamsRetryRef.current);
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[card-api] Failed to fetch CARD status:', err.message);
|
||
// Retry on network error too
|
||
if (cardTeamsRetryRef.current < 3) {
|
||
cardTeamsRetryRef.current += 1;
|
||
setTimeout(() => fetchCardStatus(), 15000 * cardTeamsRetryRef.current);
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
const fetchFindings = async () => {
|
||
setLoading(true);
|
||
try {
|
||
// Always fetch ALL findings — filtering happens client-side for instant scope switching
|
||
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();
|
||
fetchAtlasStatus();
|
||
fetchAtlasMetrics();
|
||
fetchCardStatus();
|
||
}, []); // eslint-disable-line
|
||
|
||
// Re-fetch counts when admin scope changes (per-BU counts from Postgres)
|
||
// Silent fetch — no loading spinner, just update the numbers
|
||
useEffect(() => {
|
||
const teamsParam = getActiveTeamsParam();
|
||
const url = teamsParam
|
||
? `${API_BASE}/ivanti/findings/counts?teams=${encodeURIComponent(teamsParam)}`
|
||
: `${API_BASE}/ivanti/findings/counts`;
|
||
fetch(url, { credentials: 'include' })
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then(data => { if (data) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 }); })
|
||
.catch(() => {});
|
||
// Also refresh FP workflow counts for the new scope
|
||
fetchFPWorkflowCounts();
|
||
// Refresh Atlas data for the new scope
|
||
fetchAtlasStatus();
|
||
fetchAtlasMetrics();
|
||
}, [adminScope]); // 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 };
|
||
});
|
||
}, []);
|
||
|
||
// Scope findings by selected BU teams (client-side filtering for instant switching)
|
||
const scopedFindings = useMemo(() => {
|
||
const teamsParam = getActiveTeamsParam();
|
||
if (!teamsParam) return findings; // no filter = show all
|
||
const teams = teamsParam.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
|
||
if (teams.length === 0) return findings;
|
||
return findings.filter(f =>
|
||
teams.some(t => (f.buOwnership || '').toUpperCase().includes(t))
|
||
);
|
||
}, [findings, adminScope]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// Visible findings — hidden rows removed before any other filtering
|
||
const visibleFindings = useMemo(() => {
|
||
if (hiddenRowIds.size === 0) return scopedFindings;
|
||
return scopedFindings.filter(f => !hiddenRowIds.has(String(f.id)));
|
||
}, [scopedFindings, hiddenRowIds]);
|
||
|
||
// Apply all active filters to produce the visible row set
|
||
const filtered = useMemo(() => {
|
||
let result = visibleFindings;
|
||
|
||
// 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;
|
||
}, [visibleFindings, 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]);
|
||
|
||
// Grouped view — aggregate findings by hostName + ipAddress
|
||
const groupedByHost = useMemo(() => {
|
||
if (!groupByHost) return { groups: [], singles: [] };
|
||
const map = new Map();
|
||
sorted.forEach(f => {
|
||
const hostKey = `${(f.overrides?.hostName || f.hostName || '').toLowerCase()}||${(f.ipAddress || '').toLowerCase()}`;
|
||
if (!map.has(hostKey)) {
|
||
map.set(hostKey, {
|
||
hostKey,
|
||
hostName: f.overrides?.hostName || f.hostName || '',
|
||
ipAddress: f.ipAddress || '',
|
||
findings: [],
|
||
highestSeverity: 0,
|
||
highestVrrGroup: '',
|
||
cveSet: new Set(),
|
||
});
|
||
}
|
||
const group = map.get(hostKey);
|
||
group.findings.push(f);
|
||
if (f.severity > group.highestSeverity) {
|
||
group.highestSeverity = f.severity;
|
||
group.highestVrrGroup = f.vrrGroup || '';
|
||
}
|
||
(f.cves || []).forEach(c => group.cveSet.add(c));
|
||
});
|
||
// Separate: groups with 2+ findings vs singles that stay flat
|
||
const groups = [];
|
||
const singles = [];
|
||
for (const g of map.values()) {
|
||
if (g.findings.length > 1) groups.push(g);
|
||
else singles.push(g.findings[0]);
|
||
}
|
||
groups.sort((a, b) => b.highestSeverity - a.highestSeverity);
|
||
return { groups, singles };
|
||
}, [sorted, groupByHost]);
|
||
|
||
// Combined render order for grouped mode: grouped hosts first, then singles
|
||
const groupedRenderList = useMemo(() => {
|
||
if (!groupByHost) return [];
|
||
const list = [];
|
||
groupedByHost.groups.forEach(g => list.push({ type: 'group', group: g }));
|
||
groupedByHost.singles.forEach(f => list.push({ type: 'single', finding: f }));
|
||
return list;
|
||
}, [groupByHost, groupedByHost]);
|
||
|
||
const toggleHostExpand = useCallback((hostKey) => {
|
||
setExpandedHosts(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(hostKey)) next.delete(hostKey); else next.add(hostKey);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const expandAllHosts = useCallback(() => {
|
||
setExpandedHosts(new Set(groupedByHost.groups.map(g => g.hostKey)));
|
||
}, [groupedByHost]);
|
||
|
||
const collapseAllHosts = useCallback(() => {
|
||
setExpandedHosts(new Set());
|
||
}, []);
|
||
|
||
// Select/deselect all visible rows
|
||
const toggleSelectAll = useCallback(() => {
|
||
const allVisibleIds = sorted.map(f => String(f.id));
|
||
setSelectedRowIds(prev => {
|
||
if (prev.size === allVisibleIds.length) return new Set(); // deselect all
|
||
return new Set(allVisibleIds); // select all
|
||
});
|
||
}, [sorted]);
|
||
|
||
// Prune selection to only include IDs present in the current sorted (visible) rows
|
||
useEffect(() => {
|
||
setSelectedRowIds(prev => {
|
||
const visibleIds = new Set(sorted.map(f => String(f.id)));
|
||
const next = new Set([...prev].filter(id => visibleIds.has(id)));
|
||
return next.size === prev.size ? prev : next;
|
||
});
|
||
}, [sorted]);
|
||
|
||
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]);
|
||
|
||
// Filtered submissions for QueuePanel display — hide approved and dismissed
|
||
const fpSubmissionsFiltered = useMemo(() => {
|
||
return fpSubmissions.filter(s => s.lifecycle_status !== 'approved' && !s.dismissed_at);
|
||
}, [fpSubmissions]);
|
||
|
||
// 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 handleNoteSaved = useCallback((findingId, note) => {
|
||
setFindings(prev => prev.map(f => f.id === findingId ? { ...f, note } : f));
|
||
}, []);
|
||
|
||
const handleEditSuccess = useCallback(() => {
|
||
fetchFpSubmissions();
|
||
fetchQueue();
|
||
fetchFindings();
|
||
}, [fetchFpSubmissions, fetchQueue]); // eslint-disable-line
|
||
|
||
const handleDismissSubmission = useCallback((submissionId) => {
|
||
// Optimistically remove the dismissed submission from local state
|
||
setFpSubmissions(prev => prev.filter(s => s.id !== submissionId));
|
||
}, []);
|
||
|
||
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
|
||
));
|
||
|
||
// DECOM: auto-set note and auto-hide the finding
|
||
if (queueForm.workflowType === 'DECOM') {
|
||
// Set note to DECOM
|
||
fetch(`${API_BASE}/ivanti/findings/${finding.id}/note`, {
|
||
method: 'PUT',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ note: 'DECOM' }),
|
||
}).catch(() => {});
|
||
// Update local findings state
|
||
setFindings(prev => prev.map(f =>
|
||
f.id === finding.id ? { ...f, note: 'DECOM' } : f
|
||
));
|
||
// Auto-hide the row
|
||
hideRow(finding.id);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Error adding to queue:', e);
|
||
}
|
||
setAddPopover(null);
|
||
setQueueForm({ vendor: '', workflowType: 'FP' });
|
||
}, [addPopover, queueForm, hideRow]);
|
||
|
||
// 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' || batchWorkflowType === 'GRANITE' || batchWorkflowType === 'DECOM' ? '' : 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
|
||
));
|
||
|
||
// DECOM: auto-set note and auto-hide all selected findings
|
||
if (batchWorkflowType === 'DECOM') {
|
||
const ids = [...selectedIds];
|
||
// Set notes to DECOM in parallel (fire-and-forget)
|
||
ids.forEach(id => {
|
||
fetch(`${API_BASE}/ivanti/findings/${id}/note`, {
|
||
method: 'PUT',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ note: 'DECOM' }),
|
||
}).catch(() => {});
|
||
});
|
||
// Update local findings state
|
||
setFindings(prev => prev.map(f =>
|
||
ids.includes(f.id) ? { ...f, note: 'DECOM' } : f
|
||
));
|
||
// Auto-hide all
|
||
setHiddenRowIds(prev => {
|
||
const next = new Set(prev);
|
||
ids.forEach(id => next.add(String(id)));
|
||
saveHiddenRows(next);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
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).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 style={{ marginLeft: 'auto', display: 'flex', gap: '0.25rem' }} role="tablist">
|
||
{[{ key: 'ivanti', label: 'Ivanti Findings' }, { key: 'atlas', label: 'Atlas Coverage' }].map(tab => {
|
||
const isActive = metricsTab === tab.key;
|
||
return (
|
||
<button
|
||
key={tab.key}
|
||
role="tab"
|
||
aria-selected={isActive}
|
||
tabIndex={0}
|
||
onClick={() => setMetricsTab(tab.key)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') setMetricsTab(tab.key); }}
|
||
style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
borderBottom: isActive ? '2px solid #F59E0B' : '2px solid transparent',
|
||
color: isActive ? '#F59E0B' : '#64748B',
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.7rem',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.08em',
|
||
padding: '0.375rem 0.75rem',
|
||
cursor: 'pointer',
|
||
transition: 'background 0.15s, color 0.15s'
|
||
}}
|
||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'rgba(245, 158, 11, 0.06)'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div role="tabpanel">
|
||
{metricsTab === 'ivanti' && (
|
||
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||
{/* Open vs Closed donut — per-BU counts from Postgres */}
|
||
<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={visibleFindings}
|
||
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>
|
||
)}
|
||
{metricsTab === 'atlas' && (
|
||
(atlasMetricsLoading || (!atlasMetrics && !atlasMetricsError)) ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 0', gap: '0.5rem' }}>
|
||
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
|
||
</div>
|
||
) : atlasMetricsError ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem', gap: '0.375rem' }}>
|
||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#FCA5A5' }}>{atlasMetricsError}</span>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||
{/* Host 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' }}>
|
||
Host Coverage
|
||
</div>
|
||
<AtlasCoverageDonut
|
||
hostsWithPlans={atlasMetrics.hostsWithPlans}
|
||
hostsWithoutPlans={atlasMetrics.hostsWithoutPlans}
|
||
totalHosts={atlasMetrics.totalHosts}
|
||
/>
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||
|
||
{/* Plan Types 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' }}>
|
||
Plan Types
|
||
</div>
|
||
<AtlasPlanTypeDonut
|
||
plansByType={atlasMetrics.plansByType}
|
||
totalPlans={atlasMetrics.totalPlans}
|
||
/>
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||
|
||
{/* Plan Status 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' }}>
|
||
Plan Status
|
||
</div>
|
||
<AtlasPlanStatusDonut
|
||
plansByStatus={atlasMetrics.plansByStatus}
|
||
totalPlans={atlasMetrics.totalPlans}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ----------------------------------------------------------------
|
||
Panel 1.5 — Open vs Closed trend over time
|
||
---------------------------------------------------------------- */}
|
||
{metricsTab === 'ivanti' && <AnomalyBanner />}
|
||
{metricsTab === 'ivanti' && <IvantiCountsChart teamsParam={getActiveTeamsParam()} />}
|
||
|
||
{/* ----------------------------------------------------------------
|
||
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>
|
||
<button
|
||
onClick={() => { setGroupByHost(g => !g); setExpandedHosts(new Set()); }}
|
||
title={groupByHost ? 'Switch to flat view' : 'Group findings by host'}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||
padding: '0.375rem 0.75rem',
|
||
background: groupByHost ? 'rgba(139,92,246,0.15)' : 'rgba(139,92,246,0.06)',
|
||
border: `1px solid rgba(139,92,246,${groupByHost ? '0.5' : '0.2'})`,
|
||
borderRadius: '0.375rem',
|
||
color: groupByHost ? '#A78BFA' : '#7C3AED',
|
||
cursor: 'pointer',
|
||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
}}
|
||
>
|
||
<Layers style={{ width: '13px', height: '13px' }} />
|
||
{groupByHost ? 'Grouped' : 'Group'}
|
||
</button>
|
||
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
||
<RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} />
|
||
<button
|
||
onClick={async () => {
|
||
setAtlasSyncing(true);
|
||
setAtlasError(null);
|
||
try {
|
||
const teamsParam = getActiveTeamsParam();
|
||
const syncUrl = teamsParam
|
||
? `${API_BASE}/atlas/sync?teams=${encodeURIComponent(teamsParam)}`
|
||
: `${API_BASE}/atlas/sync`;
|
||
const res = await fetch(syncUrl, { method: 'POST', credentials: 'include' });
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error || 'Atlas sync failed');
|
||
}
|
||
await fetchAtlasStatus();
|
||
await fetchAtlasMetrics();
|
||
} catch (err) {
|
||
setAtlasError(err.message);
|
||
} finally {
|
||
setAtlasSyncing(false);
|
||
}
|
||
}}
|
||
disabled={atlasSyncing || !canWrite()}
|
||
title={!canWrite() ? 'Insufficient permissions' : 'Sync Atlas action plan status'}
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
|
||
padding: '0.4rem 0.75rem',
|
||
background: atlasSyncing ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.06)',
|
||
border: '1px solid rgba(14,165,233,0.2)',
|
||
borderRadius: '0.375rem',
|
||
color: atlasSyncing ? '#475569' : '#0EA5E9',
|
||
fontSize: '0.72rem',
|
||
fontFamily: "'JetBrains Mono', monospace",
|
||
fontWeight: 600,
|
||
cursor: atlasSyncing || !canWrite() ? 'not-allowed' : 'pointer',
|
||
opacity: !canWrite() ? 0.5 : 1,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.05em',
|
||
}}
|
||
>
|
||
{atlasSyncing
|
||
? <Loader style={{ width: 13, height: 13, animation: 'spin 1s linear infinite' }} />
|
||
: <AtlasIcon style={{ width: 13, height: 13 }} />}
|
||
Atlas
|
||
</button>
|
||
<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>
|
||
)}
|
||
{atlasError && (
|
||
<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' }}>Atlas: {atlasError}</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); }}
|
||
/>
|
||
)}
|
||
{selectedRowIds.size > 0 && (
|
||
<BulkHideToolbar
|
||
count={selectedRowIds.size}
|
||
onHide={hideSelectedRows}
|
||
onClear={() => setSelectedRowIds(new Set())}
|
||
onAtlasBulk={() => setBulkAtlasOpen(true)}
|
||
canWrite={canWrite()}
|
||
/>
|
||
)}
|
||
<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 selection checkbox column — row visibility feature */}
|
||
<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',
|
||
cursor: 'pointer',
|
||
}}
|
||
onClick={toggleSelectAll}
|
||
>
|
||
{(() => {
|
||
const allVisibleIds = sorted.map(f => String(f.id));
|
||
const selectedCount = allVisibleIds.filter(id => selectedRowIds.has(id)).length;
|
||
const allSelected = allVisibleIds.length > 0 && selectedCount === allVisibleIds.length;
|
||
const someSelected = selectedCount > 0 && !allSelected;
|
||
if (allSelected) return <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />;
|
||
if (someSelected) return <MinusSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />;
|
||
return <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />;
|
||
})()}
|
||
</th>
|
||
{/* Fixed hide button column — row visibility feature */}
|
||
<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)',
|
||
}}
|
||
/>
|
||
{/* 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>
|
||
{groupByHost ? (
|
||
/* ---- Grouped-by-host view ---- */
|
||
<>
|
||
{groupedRenderList.length > 0 && (
|
||
<tr>
|
||
<td colSpan={visibleCols.length + 3} style={{ padding: '0.4rem 0.75rem', background: 'rgba(139,92,246,0.04)', borderBottom: '1px solid rgba(139,92,246,0.15)' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#A78BFA', fontWeight: '600' }}>
|
||
{groupedByHost.groups.length} grouped host{groupedByHost.groups.length !== 1 ? 's' : ''} · {groupedByHost.singles.length} single{groupedByHost.singles.length !== 1 ? 's' : ''} · {sorted.length} total
|
||
</span>
|
||
<button onClick={expandAllHosts} style={{ background: 'none', border: 'none', color: '#64748B', fontFamily: 'monospace', fontSize: '0.62rem', cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>expand all</button>
|
||
<button onClick={collapseAllHosts} style={{ background: 'none', border: 'none', color: '#64748B', fontFamily: 'monospace', fontSize: '0.62rem', cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>collapse all</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{groupedRenderList.map((item, itemIdx) => {
|
||
if (item.type === 'single') {
|
||
// Render single-finding hosts as normal flat rows
|
||
const finding = item.finding;
|
||
const isSelected = selectedIds.has(finding.id);
|
||
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (itemIdx % 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; }}
|
||
>
|
||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px', cursor: 'pointer' }} onClick={() => toggleRowSelection(finding.id)}>
|
||
{selectedRowIds.has(String(finding.id))
|
||
? <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />
|
||
: <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||
}
|
||
</td>
|
||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
|
||
<button onClick={() => hideRow(finding.id)} title="Hide this row" style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} onMouseEnter={(e) => { e.currentTarget.querySelector('svg').style.color = '#4fc3f7'; }} onMouseLeave={(e) => { e.currentTarget.querySelector('svg').style.color = '#8892a2'; }}>
|
||
<EyeOff style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||
</button>
|
||
</td>
|
||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} onClick={() => { if (queued) return; 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} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} onNoteSaved={handleNoteSaved} />
|
||
))}
|
||
</tr>
|
||
);
|
||
}
|
||
// Render grouped host header + expandable sub-rows
|
||
const group = item.group;
|
||
const isExpanded = expandedHosts.has(group.hostKey);
|
||
const sc = severityColor(group.highestVrrGroup);
|
||
return (
|
||
<React.Fragment key={group.hostKey}>
|
||
{/* Host group header — uses same columns as regular rows */}
|
||
<tr
|
||
onClick={() => toggleHostExpand(group.hostKey)}
|
||
style={{
|
||
borderBottom: '1px solid rgba(139,92,246,0.15)',
|
||
background: isExpanded ? 'rgba(139,92,246,0.06)' : 'rgba(15,26,46,0.5)',
|
||
cursor: 'pointer',
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(139,92,246,0.08)'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.background = isExpanded ? 'rgba(139,92,246,0.06)' : 'rgba(15,26,46,0.5)'; }}
|
||
>
|
||
{/* Expand/collapse icon in first fixed column */}
|
||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
|
||
{isExpanded
|
||
? <ChevronDown style={{ width: '13px', height: '13px', color: '#A78BFA' }} />
|
||
: <ChevronRight style={{ width: '13px', height: '13px', color: '#A78BFA' }} />
|
||
}
|
||
</td>
|
||
{/* Empty cells for hide + checkbox columns */}
|
||
<td style={{ padding: '0.45rem 0.5rem', width: '36px' }} />
|
||
<td style={{ padding: '0.45rem 0.5rem', width: '36px' }} />
|
||
{/* Render each column cell — show host-level summary data in the matching column positions */}
|
||
{visibleCols.map((col) => {
|
||
switch (col.key) {
|
||
case 'findingId':
|
||
return (
|
||
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35rem', padding: '0.15rem 0.5rem', borderRadius: '0.25rem', background: 'rgba(139,92,246,0.12)', border: '1px solid rgba(139,92,246,0.3)', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700', color: '#A78BFA' }}>
|
||
{group.findings.length} findings
|
||
</span>
|
||
</td>
|
||
);
|
||
case 'severity':
|
||
return (
|
||
<td key={col.key} 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' }}>
|
||
{group.highestSeverity.toFixed(2)}
|
||
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{group.highestVrrGroup}</span>
|
||
</span>
|
||
</td>
|
||
);
|
||
case 'hostName':
|
||
return (
|
||
<td key={col.key} style={{ padding: '0.45rem 0.75rem' }}>
|
||
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600' }}>
|
||
{group.hostName || '—'}
|
||
</span>
|
||
</td>
|
||
);
|
||
case 'ipAddress':
|
||
return (
|
||
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||
<span style={{ color: '#0EA5E9', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||
{group.ipAddress || '—'}
|
||
</span>
|
||
</td>
|
||
);
|
||
case 'cves':
|
||
return (
|
||
<td key={col.key} style={{ padding: '0.45rem 0.75rem' }}>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B' }}>
|
||
{group.cveSet.size} CVE{group.cveSet.size !== 1 ? 's' : ''}
|
||
</span>
|
||
</td>
|
||
);
|
||
default:
|
||
return <td key={col.key} style={{ padding: '0.45rem 0.75rem' }} />;
|
||
}
|
||
})}
|
||
</tr>
|
||
{/* Expanded sub-rows — individual findings */}
|
||
{isExpanded && group.findings.map((finding, idx) => {
|
||
const isSelected = selectedIds.has(finding.id);
|
||
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(20,30,50,0.5)' : 'rgba(15,24,42,0.5)');
|
||
const queued = isQueued(finding.id);
|
||
return (
|
||
<tr
|
||
key={finding.id}
|
||
style={{ borderBottom: '1px solid rgba(255,255,255,0.03)', background: rowBg, borderLeft: '3px solid rgba(139,92,246,0.25)' }}
|
||
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
|
||
>
|
||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px', cursor: 'pointer' }} onClick={() => toggleRowSelection(finding.id)}>
|
||
{selectedRowIds.has(String(finding.id))
|
||
? <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />
|
||
: <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||
}
|
||
</td>
|
||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
|
||
<button onClick={() => hideRow(finding.id)} title="Hide this row" style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} onMouseEnter={(e) => { e.currentTarget.querySelector('svg').style.color = '#4fc3f7'; }} onMouseLeave={(e) => { e.currentTarget.querySelector('svg').style.color = '#8892a2'; }}>
|
||
<EyeOff style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||
</button>
|
||
</td>
|
||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} onClick={() => { if (queued) return; 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} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} onNoteSaved={handleNoteSaved} />
|
||
))}
|
||
</tr>
|
||
);
|
||
})}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
{groupedRenderList.length === 0 && (
|
||
<tr>
|
||
<td colSpan={visibleCols.length + 3} 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>
|
||
)}
|
||
</>
|
||
) : (
|
||
/* ---- Flat view (default) ---- */
|
||
<>
|
||
{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; }}
|
||
>
|
||
{/* Selection checkbox cell — row visibility feature */}
|
||
<td
|
||
style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px', cursor: 'pointer' }}
|
||
onClick={() => toggleRowSelection(finding.id)}
|
||
>
|
||
{selectedRowIds.has(String(finding.id))
|
||
? <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />
|
||
: <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||
}
|
||
</td>
|
||
{/* Hide button cell — row visibility feature */}
|
||
<td
|
||
style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}
|
||
>
|
||
<button
|
||
onClick={() => hideRow(finding.id)}
|
||
title="Hide this row"
|
||
style={{
|
||
background: 'none', border: 'none', padding: 0,
|
||
cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.querySelector('svg').style.color = '#4fc3f7'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.querySelector('svg').style.color = '#8892a2'; }}
|
||
>
|
||
<EyeOff style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||
</button>
|
||
</td>
|
||
{/* 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} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} onNoteSaved={handleNoteSaved} />
|
||
))}
|
||
</tr>
|
||
);
|
||
})}
|
||
{sorted.length === 0 && (
|
||
<tr>
|
||
<td colSpan={visibleCols.length + 3} 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={visibleFindings}
|
||
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={(updatedItem) => {
|
||
setQueueItems((prev) => {
|
||
// If item already exists (in-place update), replace it
|
||
const exists = prev.some(i => i.id === updatedItem.id);
|
||
if (exists) {
|
||
return prev.map(i => i.id === updatedItem.id ? updatedItem : i);
|
||
}
|
||
// Otherwise it's a new item (redirect from completed), add it
|
||
return [...prev, updatedItem].sort((a, b) =>
|
||
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
|
||
);
|
||
});
|
||
}}
|
||
canWrite={canWrite}
|
||
fpSubmissions={fpSubmissionsFiltered}
|
||
onEditSubmission={handleEditSubmission}
|
||
onDismissSubmission={handleDismissSubmission}
|
||
cardConfigured={cardConfigured}
|
||
cardTeams={cardTeams}
|
||
onQueueRefresh={fetchQueue}
|
||
/>
|
||
<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}
|
||
/>
|
||
<CardOwnerTooltip
|
||
ip={cardTooltipIp}
|
||
hostId={cardTooltipHostId}
|
||
anchorRect={cardTooltipAnchorRect}
|
||
cache={cardTooltipCacheRef}
|
||
cardConfigured={cardConfigured}
|
||
onAction={handleCardAction}
|
||
onMouseEnter={handleCardTooltipEnter}
|
||
onMouseLeave={handleCardTooltipLeave}
|
||
/>
|
||
<CardDetailModal
|
||
isOpen={!!cardActionIp}
|
||
onClose={() => { setCardActionIp(null); setCardActionData(null); }}
|
||
ip={cardActionIp}
|
||
ownerData={cardActionData}
|
||
cardTeams={cardTeams}
|
||
/>
|
||
{atlasPanelOpen && atlasSelectedHostId && (
|
||
<AtlasSlideOutPanel
|
||
hostId={atlasSelectedHostId}
|
||
hostName={atlasSelectedHostName}
|
||
findingId={atlasSelectedFindingId}
|
||
onClose={() => {
|
||
setAtlasPanelOpen(false);
|
||
setAtlasSelectedHostId(null);
|
||
setAtlasSelectedHostName(null);
|
||
setAtlasSelectedFindingId(null);
|
||
}}
|
||
canWrite={canWrite()}
|
||
onPlanChange={fetchAtlasStatus}
|
||
/>
|
||
)}
|
||
{bulkAtlasOpen && (
|
||
<BulkAtlasModal
|
||
selectedFindings={sorted.filter(f => selectedRowIds.has(String(f.id)))}
|
||
onClose={() => setBulkAtlasOpen(false)}
|
||
onSuccess={() => { fetchAtlasStatus(); }}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|