Files
cve-dashboard/frontend/src/components/pages/IvantiTodoQueuePage.js
Jordan Ramos 79f98414c4 Add Remediate workflow type to Ivanti Queue with remediation notes
- Add 'Remediate' as a valid workflow type (vendor-required, like FP/Archer)
- Create queue_remediation_notes table with FK cascade and 5000 char limit
- Add POST/GET /api/ivanti/todo-queue/:id/notes endpoints
- Include remediation_notes_count in queue item GET response
- Add RemediationModal component for viewing/adding notes
- Add notes count badge on Remediate queue items (purple #A855F7 theme)
- Add delete confirmation warning when removing items with notes
- Append remediation notes to Jira ticket descriptions
- Add property-based tests for all correctness properties
2026-06-08 14:07:59 -06:00

1301 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet, FileText, Trash2 } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import ConsolidationModal from '../ConsolidationModal';
import LoaderModal from '../LoaderModal';
import TemplateSelector from '../TemplateSelector';
import RemediationModal from '../RemediationModal';
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor, appendRemediationNotes } from '../../utils/jiraConsolidation';
import { groupQueueItems } from '../../utils/queueGrouping';
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Styles — matches dark theme tactical intelligence aesthetic
// ---------------------------------------------------------------------------
const STYLES = {
page: {
minHeight: '60vh',
},
card: {
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.98))',
border: '1px solid rgba(14, 165, 233, 0.15)',
borderRadius: '12px',
padding: '1.5rem',
marginBottom: '1rem',
},
header: {
fontFamily: 'monospace',
fontSize: '0.7rem',
fontWeight: 700,
color: '#0EA5E9',
textTransform: 'uppercase',
letterSpacing: '0.15em',
marginBottom: '1rem',
},
toolbar: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '1rem',
flexWrap: 'wrap',
gap: '0.5rem',
},
toolbarLeft: {
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
},
toolbarRight: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
},
btn: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(14, 165, 233, 0.3)',
background: 'rgba(14, 165, 233, 0.1)',
color: '#7DD3FC',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
},
btnActive: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(14, 165, 233, 0.6)',
background: 'rgba(14, 165, 233, 0.25)',
color: '#0EA5E9',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
boxShadow: '0 0 12px rgba(14, 165, 233, 0.2)',
},
selectionCount: {
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 600,
color: '#F59E0B',
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.3)',
borderRadius: '999px',
padding: '0.25rem 0.75rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
},
tableHeader: {
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.5rem 0.75rem',
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
marginBottom: '0.5rem',
},
tableHeaderLabel: {
fontFamily: 'monospace',
fontSize: '0.65rem',
fontWeight: 700,
color: '#64748B',
textTransform: 'uppercase',
letterSpacing: '0.1em',
},
queueItem: {
display: 'flex',
alignItems: 'flex-start',
gap: '0.625rem',
padding: '0.625rem 0.75rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: 'rgba(14, 165, 233, 0.04)',
border: '1px solid rgba(14, 165, 233, 0.1)',
transition: 'background 0.15s, border-color 0.15s',
},
queueItemSelected: {
display: 'flex',
alignItems: 'flex-start',
gap: '0.625rem',
padding: '0.625rem 0.75rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: 'rgba(14, 165, 233, 0.08)',
border: '1px solid rgba(14, 165, 233, 0.3)',
transition: 'background 0.15s, border-color 0.15s',
},
checkbox: {
accentColor: '#0EA5E9',
width: '16px',
height: '16px',
flexShrink: 0,
marginTop: '2px',
cursor: 'pointer',
},
selectAllCheckbox: {
accentColor: '#0EA5E9',
width: '14px',
height: '14px',
cursor: 'pointer',
},
floatingBar: {
position: 'fixed',
bottom: '1.5rem',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 1.25rem',
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(15, 23, 42, 0.99))',
border: '1px solid rgba(14, 165, 233, 0.3)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 16px rgba(14, 165, 233, 0.1)',
zIndex: 50,
},
floatingBarBadge: {
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 600,
color: '#F59E0B',
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.3)',
borderRadius: '999px',
padding: '0.25rem 0.75rem',
},
btnSuccess: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(16, 185, 129, 0.4)',
background: 'rgba(16, 185, 129, 0.15)',
color: '#6EE7B7',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
},
btnDisabled: {
opacity: 0.4,
cursor: 'not-allowed',
},
btnCancel: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(148, 163, 184, 0.3)',
background: 'rgba(148, 163, 184, 0.08)',
color: '#94A3B8',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
},
modal: {
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
modalBackdrop: {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(4px)',
},
modalContent: {
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: 101,
},
input: {
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',
},
sectionHeaderInventory: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
marginTop: '0.5rem',
borderBottom: '1px solid rgba(16, 185, 129, 0.2)',
cursor: 'pointer',
userSelect: 'none',
fontFamily: 'monospace',
fontSize: '0.7rem',
fontWeight: 700,
color: '#10B981',
textTransform: 'uppercase',
letterSpacing: '0.1em',
},
sectionHeaderVendor: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
marginTop: '0.5rem',
borderBottom: '1px solid rgba(148, 163, 184, 0.15)',
cursor: 'pointer',
userSelect: 'none',
fontFamily: 'monospace',
fontSize: '0.7rem',
fontWeight: 700,
color: '#94A3B8',
textTransform: 'uppercase',
letterSpacing: '0.1em',
},
sectionCount: {
fontFamily: 'monospace',
fontSize: '0.65rem',
fontWeight: 600,
color: '#64748B',
marginLeft: '0.25rem',
},
};
// ---------------------------------------------------------------------------
// IvantiTodoQueuePage — Full-page Ivanti queue with multi-select support
// ---------------------------------------------------------------------------
export default function IvantiTodoQueuePage() {
const { canWrite } = useAuth();
// Queue data state
const [queueItems, setQueueItems] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Ticket link badges state (Requirement 6.3, 6.4, 6.5)
const [ticketLinks, setTicketLinks] = useState({});
// Selection mode state (Requirement 1.1)
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState(new Set());
// Consolidation modal state (Requirement 2.3)
const [showConsolidationModal, setShowConsolidationModal] = useState(false);
// Single-item Jira creation modal state (Requirement 2.4)
const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
const [showLoaderModal, setShowLoaderModal] = useState(false);
const [singleJiraItem, setSingleJiraItem] = useState(null);
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue', project_key: '', issue_type: '' });
const [singleJiraError, setSingleJiraError] = useState(null);
const [singleJiraSaving, setSingleJiraSaving] = useState(false);
const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null);
// Collapse state for grouped sections (Requirement 2.2, 2.7)
const [collapsedSections, setCollapsedSections] = useState({});
// Archer Template Selector panel — tracks which item ID has the panel expanded (Requirement 5.1)
const [templatePanelOpenId, setTemplatePanelOpenId] = useState(null);
// Remediation Modal state — tracks which item has the modal open
const [remediationModalItem, setRemediationModalItem] = useState(null);
// Local note counts — allows updating badge without full page reload
const [localNoteCounts, setLocalNoteCounts] = useState({});
// Delete confirmation dialog state (Requirement 7)
const [deleteConfirmItem, setDeleteConfirmItem] = useState(null);
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
const fetchQueue = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue`, { credentials: 'include' });
const data = await res.json();
if (res.ok) {
// Parse cves from cves_json if not already parsed
const parsed = data.map((item) => {
if (item.cves) return item;
let cves = [];
if (item.cves_json) {
try { cves = JSON.parse(item.cves_json); } catch { cves = []; }
}
return { ...item, cves };
});
setQueueItems(parsed);
} else {
setError(data.error || 'Failed to fetch queue items.');
}
} catch (e) {
setError('Network error — could not fetch queue items.');
console.error('Error fetching queue:', e);
} finally {
setLoading(false);
}
}, []);
// ---------------------------------------------------------------------------
// Fetch ticket link associations (Requirements 6.3, 6.4, 6.5)
// ---------------------------------------------------------------------------
const fetchTicketLinks = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/ticket-links`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setTicketLinks(data.links || {});
}
} catch (e) {
console.error('Error fetching ticket links:', e);
}
}, []);
useEffect(() => {
fetchQueue();
fetchTicketLinks();
}, [fetchQueue, fetchTicketLinks]);
// ---------------------------------------------------------------------------
// Visible items — only pending items are selectable
// ---------------------------------------------------------------------------
const visibleItems = useMemo(() => {
return queueItems.filter((item) => item.status === 'pending');
}, [queueItems]);
// ---------------------------------------------------------------------------
// Grouped sections — hybrid Inventory + vendor grouping (Requirements 1.11.7)
// ---------------------------------------------------------------------------
const groupedSections = useMemo(() => groupQueueItems(visibleItems), [visibleItems]);
// ---------------------------------------------------------------------------
// Toggle section collapse (Requirement 2.2, 2.7)
// ---------------------------------------------------------------------------
const toggleSection = useCallback((sectionKey) => {
setCollapsedSections((prev) => ({
...prev,
[sectionKey]: !prev[sectionKey],
}));
}, []);
// ---------------------------------------------------------------------------
// Toggle Archer Template Selector panel (Requirement 5.1)
// ---------------------------------------------------------------------------
const toggleTemplatePanel = useCallback((itemId) => {
setTemplatePanelOpenId((prev) => (prev === itemId ? null : itemId));
}, []);
// ---------------------------------------------------------------------------
// Selection mode toggle (Requirement 1.1, 1.5)
// When deactivated, clear all selections
// ---------------------------------------------------------------------------
const toggleSelectionMode = useCallback(() => {
setSelectionMode((prev) => {
if (prev) {
// Deactivating — clear selections (Requirement 1.5)
setSelectedIds(new Set());
}
return !prev;
});
}, []);
// ---------------------------------------------------------------------------
// Individual item selection toggle (Requirement 1.2)
// ---------------------------------------------------------------------------
const toggleItemSelection = useCallback((id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// ---------------------------------------------------------------------------
// Select All toggle (Requirement 1.4)
// Toggles all visible (filtered) queue item IDs into/out of selectedIds
// ---------------------------------------------------------------------------
const allVisibleSelected = useMemo(() => {
if (visibleItems.length === 0) return false;
return visibleItems.every((item) => selectedIds.has(item.id));
}, [visibleItems, selectedIds]);
const someVisibleSelected = useMemo(() => {
if (visibleItems.length === 0) return false;
return visibleItems.some((item) => selectedIds.has(item.id)) && !allVisibleSelected;
}, [visibleItems, selectedIds, allVisibleSelected]);
const toggleSelectAll = useCallback(() => {
if (allVisibleSelected) {
// Deselect all visible
setSelectedIds((prev) => {
const next = new Set(prev);
visibleItems.forEach((item) => next.delete(item.id));
return next;
});
} else {
// Select all visible
setSelectedIds((prev) => {
const next = new Set(prev);
visibleItems.forEach((item) => next.add(item.id));
return next;
});
}
}, [allVisibleSelected, visibleItems]);
// ---------------------------------------------------------------------------
// Preserve selections on scroll/re-render (Requirement 1.6)
// Clean up selectedIds that no longer exist in the queue
// ---------------------------------------------------------------------------
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const validIds = new Set(queueItems.map((i) => i.id));
const next = new Set([...prev].filter((id) => validIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [queueItems]);
// ---------------------------------------------------------------------------
// Selected queue items (full objects) for modal use
// ---------------------------------------------------------------------------
const selectedQueueItems = useMemo(() => {
return queueItems.filter(item => selectedIds.has(item.id));
}, [queueItems, selectedIds]);
// ---------------------------------------------------------------------------
// Floating action bar — "Create Jira Ticket" handler (Requirements 2.3, 2.4)
// ---------------------------------------------------------------------------
const handleCreateJiraTicket = useCallback(async () => {
if (selectedIds.size === 0) return;
if (selectedIds.size === 1) {
// Single item — open single-item Jira creation modal (Requirement 2.4)
const item = queueItems.find(i => selectedIds.has(i.id));
if (!item) return;
setSingleJiraItem(item);
const items = [item];
let description = generateConsolidatedDescription(items);
// If the item is Remediate, fetch its notes and append to description (Requirement 8)
if (item.workflow_type === 'Remediate') {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, { credentials: 'include' });
if (res.ok) {
const notes = await res.json();
if (notes.length > 0) {
const notesMap = { [item.id]: notes };
description = appendRemediationNotes(description, notesMap);
}
}
} catch (_e) { /* best effort — proceed without notes */ }
}
setSingleJiraForm({
cve_id: extractFirstCve(items),
vendor: extractCommonVendor(items),
summary: generateConsolidatedSummary(items),
description,
source_context: 'ivanti_queue',
project_key: '',
issue_type: '',
});
setSingleJiraError(null);
setSingleJiraSummaryError(null);
setShowSingleJiraModal(true);
} else {
// Multiple items — open Consolidation Modal (Requirement 2.3)
setShowConsolidationModal(true);
}
}, [selectedIds, queueItems]);
// ---------------------------------------------------------------------------
// Consolidation modal success handler
// ---------------------------------------------------------------------------
const handleConsolidationSuccess = useCallback(() => {
setShowConsolidationModal(false);
setSelectedIds(new Set());
setSelectionMode(false);
fetchQueue();
fetchTicketLinks();
}, [fetchQueue, fetchTicketLinks]);
// ---------------------------------------------------------------------------
// Single-item Jira creation — submit handler
// ---------------------------------------------------------------------------
const submitSingleJira = useCallback(async () => {
setSingleJiraSummaryError(null);
const trimmedSummary = (singleJiraForm.summary || '').trim();
if (!trimmedSummary) {
setSingleJiraSummaryError('Summary is required.');
return;
}
if (trimmedSummary.length > 255) {
setSingleJiraSummaryError('Summary must be 255 characters or fewer.');
return;
}
setSingleJiraError(null);
setSingleJiraSaving(true);
try {
const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(singleJiraForm),
});
const data = await res.json();
if (!res.ok && res.status !== 207) {
throw new Error(data.error || `HTTP ${res.status}`);
}
// If we have a ticket ID and a queue item, link them via junction table
if (data.id && singleJiraItem) {
try {
await fetch(`${API_BASE}/jira-tickets/${data.id}/queue-items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ queue_item_ids: [singleJiraItem.id] }),
});
} catch (_) { /* junction link is best-effort */ }
}
setShowSingleJiraModal(false);
setSingleJiraItem(null);
setSelectedIds(new Set());
setSelectionMode(false);
fetchQueue();
fetchTicketLinks();
} catch (err) {
setSingleJiraError(err.message);
} finally {
setSingleJiraSaving(false);
}
}, [singleJiraForm, singleJiraItem, fetchQueue, fetchTicketLinks]);
// ---------------------------------------------------------------------------
// Cancel selection mode from floating bar
// ---------------------------------------------------------------------------
const cancelSelection = useCallback(() => {
setSelectedIds(new Set());
setSelectionMode(false);
}, []);
// ---------------------------------------------------------------------------
// Delete queue item with confirmation for Remediate items with notes
// ---------------------------------------------------------------------------
const initiateDelete = useCallback((item) => {
const noteCount = localNoteCounts[item.id] !== undefined
? localNoteCounts[item.id]
: (item.remediation_notes_count || 0);
if (item.workflow_type === 'Remediate' && noteCount > 0) {
setDeleteConfirmItem({ ...item, _noteCount: noteCount });
} else {
performDelete(item.id);
}
}, [localNoteCounts]);
const performDelete = 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((i) => i.id !== id));
}
} catch (e) {
console.error('Error deleting queue item:', e);
}
setDeleteConfirmItem(null);
}, []);
const cancelDelete = useCallback(() => {
setDeleteConfirmItem(null);
}, []);
// ---------------------------------------------------------------------------
// Workflow type color helper
// ---------------------------------------------------------------------------
const getWorkflowColor = (workflowType) => {
switch (workflowType) {
case 'FP': return { col: '#F59E0B', rgb: '245,158,11' };
case 'Archer': return { col: '#0EA5E9', rgb: '14,165,233' };
case 'CARD': return { col: '#10B981', rgb: '16,185,129' };
case 'GRANITE': return { col: '#A1887F', rgb: '161,136,127' };
case 'DECOM': return { col: '#EF4444', rgb: '239,68,68' };
case 'Remediate': return { col: '#A855F7', rgb: '168,85,247' };
default: return { col: '#94A3B8', rgb: '148,163,184' };
}
};
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div style={STYLES.page}>
<div style={STYLES.card}>
{/* Page header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '1.25rem' }}>
<ListTodo style={{ width: '20px', height: '20px', color: '#0EA5E9' }} />
<span style={STYLES.header}>Ivanti Todo Queue</span>
</div>
{/* Toolbar */}
<div style={STYLES.toolbar}>
<div style={STYLES.toolbarLeft}>
{/* Select toggle button (Requirement 1.1) */}
{canWrite() && (
<button
onClick={toggleSelectionMode}
style={selectionMode ? STYLES.btnActive : STYLES.btn}
title={selectionMode ? 'Exit selection mode' : 'Enter selection mode'}
>
{selectionMode
? <><CheckSquare style={{ width: '14px', height: '14px' }} /> Selecting</>
: <><Square style={{ width: '14px', height: '14px' }} /> Select</>
}
</button>
)}
{/* Selection count indicator (Requirement 1.3) */}
{selectionMode && selectedIds.size > 0 && (
<span style={STYLES.selectionCount}>
{selectedIds.size} selected
</span>
)}
</div>
<div style={STYLES.toolbarRight}>
{/* Refresh button */}
<button
onClick={fetchQueue}
style={STYLES.btn}
disabled={loading}
title="Refresh queue"
>
<RefreshCw style={{ width: '14px', height: '14px', animation: loading ? 'spin 1s linear infinite' : 'none' }} />
Refresh
</button>
</div>
</div>
{/* Error state */}
{error && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.75rem 1rem',
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '0.5rem',
marginBottom: '1rem',
}}>
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#FCA5A5' }}>{error}</span>
</div>
)}
{/* Loading state */}
{loading && queueItems.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto' }} />
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569', marginTop: '0.75rem' }}>
Loading queue items...
</div>
</div>
)}
{/* Empty state */}
{!loading && queueItems.length === 0 && !error && (
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<ListTodo style={{ width: '32px', height: '32px', color: '#1E293B', margin: '0 auto' }} />
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#334155', marginTop: '0.75rem' }}>
No items in queue.
</div>
</div>
)}
{/* Queue items table */}
{!loading && visibleItems.length > 0 && (
<>
{/* Table header with Select All (Requirement 1.4) */}
<div style={STYLES.tableHeader}>
{selectionMode && (
<input
type="checkbox"
checked={allVisibleSelected}
ref={(el) => { if (el) el.indeterminate = someVisibleSelected; }}
onChange={toggleSelectAll}
style={STYLES.selectAllCheckbox}
title={allVisibleSelected ? 'Deselect all' : 'Select all'}
aria-label="Select all queue items"
/>
)}
<span style={{ ...STYLES.tableHeaderLabel, flex: 1 }}>Finding</span>
<span style={{ ...STYLES.tableHeaderLabel, width: '80px', textAlign: 'center' }}>Type</span>
<span style={{ ...STYLES.tableHeaderLabel, width: '120px' }}>Vendor</span>
<span style={{ ...STYLES.tableHeaderLabel, width: '120px' }}>Host</span>
</div>
{/* Grouped sections with collapsible headers (Requirements 2.1, 2.32.6, 3.1, 3.2) */}
{groupedSections.map((section) => {
const isCollapsed = !!collapsedSections[section.key];
return (
<div key={section.key}>
{/* Section Header */}
<div
onClick={() => toggleSection(section.key)}
style={section.type === 'inventory' ? STYLES.sectionHeaderInventory : STYLES.sectionHeaderVendor}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSection(section.key);
}
}}
aria-expanded={!isCollapsed}
aria-label={`${section.label} section, ${section.items.length} items`}
>
{isCollapsed
? <ChevronRight style={{ width: '14px', height: '14px' }} />
: <ChevronDown style={{ width: '14px', height: '14px' }} />
}
<span>{section.label}</span>
<span style={STYLES.sectionCount}>({section.items.length})</span>
</div>
{/* Section Body — only rendered when expanded */}
{!isCollapsed && section.items.map((item) => {
const isSelected = selectedIds.has(item.id);
const wfColor = getWorkflowColor(item.workflow_type);
const cves = item.cves || [];
const cveDisplay = cves.length > 0
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
: '';
const isArcherItem = item.workflow_type === 'Archer';
const isRemediateItem = item.workflow_type === 'Remediate';
const isTemplatePanelOpen = templatePanelOpenId === item.id;
const noteCount = localNoteCounts[item.id] !== undefined
? localNoteCounts[item.id]
: (item.remediation_notes_count || 0);
return (
<React.Fragment key={item.id}>
<div
style={isSelected ? STYLES.queueItemSelected : STYLES.queueItem}
onClick={selectionMode ? () => toggleItemSelection(item.id) : undefined}
role={selectionMode ? 'button' : undefined}
tabIndex={selectionMode ? 0 : undefined}
onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined}
>
{/* Selection checkbox (Requirement 1.2) */}
{selectionMode && (
<input
type="checkbox"
checked={isSelected}
onChange={(e) => { e.stopPropagation(); toggleItemSelection(item.id); }}
onClick={(e) => e.stopPropagation()}
style={STYLES.checkbox}
aria-label={`Select ${item.finding_title || item.finding_id}`}
/>
)}
{/* Finding info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 600,
color: '#CBD5E1',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.finding_title || item.finding_id}>
{item.finding_title || item.finding_id}
</div>
{cveDisplay && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.65rem',
color: '#64748B',
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={cves.join(', ')}>
{cveDisplay}
</div>
)}
</div>
{/* Archer Template toggle button (Requirement 5.1) */}
{isArcherItem && (
<button
onClick={(e) => { e.stopPropagation(); toggleTemplatePanel(item.id); }}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.3rem',
padding: '0.2rem 0.5rem',
borderRadius: '4px',
border: isTemplatePanelOpen
? '1px solid rgba(0, 212, 255, 0.5)'
: '1px solid rgba(0, 212, 255, 0.2)',
background: isTemplatePanelOpen
? 'rgba(0, 212, 255, 0.15)'
: 'rgba(0, 212, 255, 0.05)',
color: isTemplatePanelOpen ? '#00d4ff' : '#7DD3FC',
cursor: 'pointer',
fontSize: '0.62rem',
fontFamily: 'monospace',
fontWeight: 600,
flexShrink: 0,
transition: 'all 0.2s',
}}
title={isTemplatePanelOpen ? 'Hide template selector' : 'Show template selector'}
aria-expanded={isTemplatePanelOpen}
aria-label="Toggle template selector"
>
<FileText style={{ width: '11px', height: '11px' }} />
{isTemplatePanelOpen ? 'Hide' : 'Template'}
</button>
)}
{/* Remediation Notes button (Requirement 5.1, 6.1, 6.2) */}
{isRemediateItem && (
<button
onClick={(e) => { e.stopPropagation(); setRemediationModalItem(item); }}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.3rem',
padding: '0.2rem 0.5rem',
borderRadius: '4px',
border: '1px solid rgba(168, 85, 247, 0.2)',
background: 'rgba(168, 85, 247, 0.05)',
color: '#C084FC',
cursor: 'pointer',
fontSize: '0.62rem',
fontFamily: 'monospace',
fontWeight: 600,
flexShrink: 0,
transition: 'all 0.2s',
position: 'relative',
}}
title="View remediation notes"
aria-label="Remediation notes"
>
<FileText style={{ width: '11px', height: '11px' }} />
Notes
{noteCount > 0 && (
<span style={{
fontFamily: 'monospace',
fontSize: '0.55rem',
fontWeight: 700,
color: '#A855F7',
background: 'rgba(168, 85, 247, 0.15)',
border: '1px solid rgba(168, 85, 247, 0.3)',
borderRadius: '999px',
padding: '0.05rem 0.3rem',
marginLeft: '0.15rem',
}}>
{noteCount > 99 ? '99+' : noteCount}
</span>
)}
</button>
)}
{/* Delete button for Remediate items (Requirement 7) */}
{isRemediateItem && canWrite() && (
<button
onClick={(e) => { e.stopPropagation(); initiateDelete(item); }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#475569',
padding: '0.2rem',
borderRadius: '4px',
display: 'inline-flex',
alignItems: 'center',
flexShrink: 0,
transition: 'color 0.15s',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#EF4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#475569'; }}
title="Delete queue item"
aria-label="Delete queue item"
>
<Trash2 style={{ width: '13px', height: '13px' }} />
</button>
)}
{/* Ticket link badge (Requirements 6.3, 6.4) */}
{ticketLinks[item.id] && (
<a
href={ticketLinks[item.id].jira_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={{
fontFamily: 'monospace',
fontSize: '0.6rem',
fontWeight: 700,
color: '#6EE7B7',
background: 'rgba(16, 185, 129, 0.1)',
border: '1px solid rgba(16, 185, 129, 0.3)',
borderRadius: '999px',
padding: '0.15rem 0.5rem',
textDecoration: 'none',
whiteSpace: 'nowrap',
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
transition: 'all 0.2s',
}}
title={`Open ${ticketLinks[item.id].ticket_key} in Jira`}
>
{ticketLinks[item.id].ticket_key}
</a>
)}
{/* Workflow type badge */}
<div style={{
width: '80px',
textAlign: 'center',
flexShrink: 0,
}}>
<span style={{
fontFamily: 'monospace',
fontSize: '0.6rem',
fontWeight: 700,
color: wfColor.col,
background: `rgba(${wfColor.rgb}, 0.1)`,
border: `1px solid rgba(${wfColor.rgb}, 0.3)`,
borderRadius: '4px',
padding: '0.15rem 0.4rem',
textTransform: 'uppercase',
}}>
{item.workflow_type}
</span>
</div>
{/* Vendor */}
<div style={{
width: '120px',
flexShrink: 0,
fontFamily: 'monospace',
fontSize: '0.68rem',
color: '#94A3B8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.vendor}>
{item.vendor || '—'}
</div>
{/* Hostname / IP */}
<div style={{
width: '120px',
flexShrink: 0,
minWidth: 0,
}}>
{item.hostname && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.65rem',
color: '#94A3B8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.hostname}>
{item.hostname}
</div>
)}
{item.ip_address && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.62rem',
color: '#10B981',
marginTop: item.hostname ? '1px' : 0,
}}>
{item.ip_address}
</div>
)}
</div>
</div>
{/* Archer Template Selector expandable panel (Requirement 5.1) */}
{isArcherItem && isTemplatePanelOpen && (
<div style={{
marginBottom: '0.5rem',
marginLeft: selectionMode ? '1.625rem' : '0',
padding: '0.75rem',
background: 'linear-gradient(135deg, rgba(22, 33, 62, 0.6), rgba(15, 23, 42, 0.8))',
border: '1px solid rgba(0, 212, 255, 0.15)',
borderTop: 'none',
borderRadius: '0 0 8px 8px',
}}>
<TemplateSelector />
</div>
)}
</React.Fragment>
);
})}
</div>
);
})}
</>
)}
{/* Completed items count */}
{!loading && queueItems.filter(i => i.status === 'complete').length > 0 && (
<div style={{
marginTop: '1rem',
padding: '0.5rem 0.75rem',
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
fontFamily: 'monospace',
fontSize: '0.68rem',
color: '#334155',
}}>
{queueItems.filter(i => i.status === 'complete').length} completed item(s) hidden
</div>
)}
</div>
{/* Floating Action Bar (Requirements 2.1, 2.2) */}
{selectionMode && selectedIds.size > 0 && (
<div style={STYLES.floatingBar}>
<span style={STYLES.floatingBarBadge}>
{selectedIds.size} selected
</span>
<button
onClick={handleCreateJiraTicket}
disabled={selectedIds.size === 0}
style={selectedIds.size === 0 ? { ...STYLES.btnSuccess, ...STYLES.btnDisabled } : STYLES.btnSuccess}
title={selectedIds.size === 1 ? 'Create Jira ticket for selected item' : `Create consolidated Jira ticket for ${selectedIds.size} items`}
>
<Plus style={{ width: '14px', height: '14px' }} />
Create Jira Ticket
</button>
{(() => {
const selectedItems = queueItems.filter(i => selectedIds.has(i.id));
const hasCardGranite = selectedItems.some(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
return hasCardGranite ? (
<button
onClick={() => setShowLoaderModal(true)}
style={STYLES.btnSuccess}
title="Generate Granite Team_Device Loader Sheet from selected items"
>
<FileSpreadsheet style={{ width: '14px', height: '14px' }} />
Generate Loader Sheet
</button>
) : null;
})()}
<button
onClick={cancelSelection}
style={STYLES.btnCancel}
title="Cancel selection"
>
<X style={{ width: '14px', height: '14px' }} />
Cancel
</button>
</div>
)}
{/* Single-item Jira Creation Modal (Requirement 2.4) */}
{showSingleJiraModal && (
<div style={STYLES.modal}>
<div style={STYLES.modalBackdrop} onClick={() => setShowSingleJiraModal(false)} />
<div style={STYLES.modalContent}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '1rem' }}>Create Jira Ticket</h3>
<button onClick={() => setShowSingleJiraModal(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X style={{ width: '18px', height: '18px' }} /></button>
</div>
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginTop: 0, marginBottom: '1rem' }}>
Create a Jira issue for: <span style={{ color: '#7DD3FC' }}>{singleJiraItem?.finding_title || singleJiraItem?.finding_id}</span>
</p>
{singleJiraError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem' }}>{singleJiraError}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID (optional)</label>
<input style={STYLES.input} placeholder="e.g. CVE-2024-12345" value={singleJiraForm.cve_id} onChange={e => setSingleJiraForm(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={STYLES.input} placeholder="e.g. Microsoft" value={singleJiraForm.vendor} onChange={e => setSingleJiraForm(f => ({ ...f, vendor: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary <span style={{ color: '#F59E0B' }}>*</span></label>
<input
style={{ ...STYLES.input, ...(singleJiraSummaryError ? { borderColor: 'rgba(239, 68, 68, 0.6)' } : {}) }}
placeholder="Issue summary (max 255 chars)"
value={singleJiraForm.summary}
onChange={e => { setSingleJiraForm(f => ({ ...f, summary: e.target.value })); if (singleJiraSummaryError) setSingleJiraSummaryError(null); }}
maxLength={255}
/>
{singleJiraSummaryError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.25rem' }}>{singleJiraSummaryError}</div>}
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Source Context</label>
<input
style={{ ...STYLES.input, opacity: 0.7, cursor: 'not-allowed' }}
value="ivanti_queue"
disabled
/>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Description</label>
<textarea
style={{ ...STYLES.input, minHeight: '100px', resize: 'vertical' }}
placeholder="Detailed description..."
value={singleJiraForm.description}
onChange={e => setSingleJiraForm(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={STYLES.input} placeholder="Uses .env default" value={singleJiraForm.project_key} onChange={e => {
const newKey = e.target.value.toUpperCase();
const wasVendor = isVendorProject(singleJiraForm.project_key, VENDOR_PROJECT_KEYS);
const isNowVendor = isVendorProject(newKey, VENDOR_PROJECT_KEYS);
setSingleJiraForm(f => ({
...f,
project_key: newKey,
issue_type: (wasVendor !== isNowVendor) ? '' : f.issue_type,
}));
}} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type</label>
<select style={{ ...STYLES.input, cursor: 'pointer' }} value={singleJiraForm.issue_type} onChange={e => setSingleJiraForm(f => ({ ...f, issue_type: e.target.value }))}>
<option value="">Story (default)</option>
{getIssueTypesForProject(singleJiraForm.project_key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES).map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
</div>
<button
style={{ ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }}
onClick={submitSingleJira}
disabled={singleJiraSaving}
>
{singleJiraSaving ? <Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} /> : <CheckCircle style={{ width: '14px', height: '14px' }} />}
Create in Jira
</button>
</div>
</div>
</div>
)}
{/* Consolidation Modal (Requirement 2.3) */}
{showConsolidationModal && (
<ConsolidationModal
items={selectedQueueItems}
onClose={() => setShowConsolidationModal(false)}
onSuccess={handleConsolidationSuccess}
/>
)}
{/* Granite Loader Sheet Modal */}
<LoaderModal
isOpen={showLoaderModal}
onClose={() => setShowLoaderModal(false)}
initialDevices={showLoaderModal ? queueItems.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' })) : null}
/>
{/* Remediation Notes Modal */}
{remediationModalItem && (
<RemediationModal
item={remediationModalItem}
onClose={() => setRemediationModalItem(null)}
onNoteAdded={() => {
setLocalNoteCounts((prev) => ({
...prev,
[remediationModalItem.id]: (prev[remediationModalItem.id] !== undefined
? prev[remediationModalItem.id]
: (remediationModalItem.remediation_notes_count || 0)) + 1,
}));
}}
/>
)}
{/* Delete Confirmation Dialog (Requirement 7) */}
{deleteConfirmItem && (
<div style={STYLES.modal}>
<div style={STYLES.modalBackdrop} onClick={cancelDelete} />
<div style={{ ...STYLES.modalContent, maxWidth: '400px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem' }}>
<AlertCircle style={{ width: '20px', height: '20px', color: '#EF4444' }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: 700, color: '#F8FAFC' }}>
Delete Queue Item
</span>
</div>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#CBD5E1', lineHeight: 1.6, margin: '0 0 1rem 0' }}>
This item has <span style={{ color: '#A855F7', fontWeight: 700 }}>{deleteConfirmItem._noteCount}</span> remediation note{deleteConfirmItem._noteCount !== 1 ? 's' : ''}.
Deleting this item will <span style={{ color: '#EF4444', fontWeight: 600 }}>permanently delete</span> all associated remediation notes.
</p>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button
onClick={cancelDelete}
style={STYLES.btnCancel}
>
Cancel
</button>
<button
onClick={() => performDelete(deleteConfirmItem.id)}
style={{
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(239, 68, 68, 0.4)',
background: 'rgba(239, 68, 68, 0.15)',
color: '#FCA5A5',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
}}
>
<Trash2 style={{ width: '14px', height: '14px' }} />
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}