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
This commit is contained in:
Jordan Ramos
2026-06-08 14:07:59 -06:00
parent d4c428248a
commit 79f98414c4
13 changed files with 1803 additions and 28 deletions

View File

@@ -1,10 +1,11 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet, FileText } from 'lucide-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 { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
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';
@@ -315,6 +316,15 @@ export default function IvantiTodoQueuePage() {
// 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
// ---------------------------------------------------------------------------
@@ -479,7 +489,7 @@ export default function IvantiTodoQueuePage() {
// ---------------------------------------------------------------------------
// Floating action bar — "Create Jira Ticket" handler (Requirements 2.3, 2.4)
// ---------------------------------------------------------------------------
const handleCreateJiraTicket = useCallback(() => {
const handleCreateJiraTicket = useCallback(async () => {
if (selectedIds.size === 0) return;
if (selectedIds.size === 1) {
@@ -488,11 +498,27 @@ export default function IvantiTodoQueuePage() {
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: generateConsolidatedDescription(items),
description,
source_context: 'ivanti_queue',
project_key: '',
issue_type: '',
@@ -579,17 +605,51 @@ export default function IvantiTodoQueuePage() {
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' };
default: return { col: '#94A3B8', rgb: '148,163,184' };
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' };
}
};
@@ -739,7 +799,11 @@ export default function IvantiTodoQueuePage() {
? 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}>
@@ -823,6 +887,75 @@ export default function IvantiTodoQueuePage() {
</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
@@ -1099,6 +1232,69 @@ export default function IvantiTodoQueuePage() {
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>
);
}