903 lines
34 KiB
JavaScript
903 lines
34 KiB
JavaScript
|
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||
|
|
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle } from 'lucide-react';
|
||
|
|
import { useAuth } from '../../contexts/AuthContext';
|
||
|
|
import ConsolidationModal from '../ConsolidationModal';
|
||
|
|
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
||
|
|
|
||
|
|
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',
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 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 [singleJiraItem, setSingleJiraItem] = useState(null);
|
||
|
|
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue' });
|
||
|
|
const [singleJiraError, setSingleJiraError] = useState(null);
|
||
|
|
const [singleJiraSaving, setSingleJiraSaving] = useState(false);
|
||
|
|
const [singleJiraSummaryError, setSingleJiraSummaryError] = 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]);
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 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(() => {
|
||
|
|
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];
|
||
|
|
setSingleJiraForm({
|
||
|
|
cve_id: extractFirstCve(items),
|
||
|
|
vendor: extractCommonVendor(items),
|
||
|
|
summary: generateConsolidatedSummary(items),
|
||
|
|
description: generateConsolidatedDescription(items),
|
||
|
|
source_context: 'ivanti_queue',
|
||
|
|
});
|
||
|
|
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);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 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' };
|
||
|
|
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>
|
||
|
|
|
||
|
|
{/* Queue item rows */}
|
||
|
|
{visibleItems.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}` : '')
|
||
|
|
: '';
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={item.id}
|
||
|
|
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>
|
||
|
|
|
||
|
|
{/* 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>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 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>
|
||
|
|
<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>
|
||
|
|
<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}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|