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

@@ -0,0 +1,79 @@
/**
* Property-Based Test: Note Count Badge Formatting
*
* Feature: ivanti-queue-remediation
* Property 12: Note count badge formatting
*
* For any integer count N where N > 0, the badge display SHALL show the string
* representation of N when N <= 99, and "99+" when N > 99. For N = 0, no badge
* SHALL be displayed.
*
* **Validates: Requirements 6.1, 6.2**
*/
import fc from 'fast-check';
// ---------------------------------------------------------------------------
// Pure function under test — extracted badge display logic
// ---------------------------------------------------------------------------
/**
* Determines the badge display value for a given note count.
* Returns null when no badge should be shown (count = 0).
*
* @param {number} count - The number of remediation notes
* @returns {string|null} The badge text, or null if no badge
*/
function formatBadgeCount(count) {
if (count <= 0) return null;
if (count > 99) return '99+';
return String(count);
}
// ---------------------------------------------------------------------------
// Property 12: Note count badge formatting
// ---------------------------------------------------------------------------
describe('Feature: ivanti-queue-remediation, Property 12: Note count badge formatting', () => {
it('displays the exact count for N where 1 <= N <= 99', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 99 }),
(count) => {
const badge = formatBadgeCount(count);
expect(badge).toBe(String(count));
}
),
{ numRuns: 100 }
);
});
it('displays "99+" for any count exceeding 99', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 100000 }),
(count) => {
const badge = formatBadgeCount(count);
expect(badge).toBe('99+');
}
),
{ numRuns: 100 }
);
});
it('returns null (no badge) for count = 0', () => {
const badge = formatBadgeCount(0);
expect(badge).toBeNull();
});
it('returns null (no badge) for negative counts', () => {
fc.assert(
fc.property(
fc.integer({ min: -1000, max: 0 }),
(count) => {
const badge = formatBadgeCount(count);
expect(badge).toBeNull();
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,212 @@
/**
* Property-Based Tests: Ivanti Queue Remediation — Description Generation
*
* Feature: ivanti-queue-remediation
*
* Property 10: Description generation appends remediation notes iff notes exist
* Property 11: Non-Remediate description unchanged
*
* **Validates: Requirements 8.1, 8.2, 8.3, 8.4**
*/
import fc from 'fast-check';
import { generateConsolidatedDescription, appendRemediationNotes } from '../utils/jiraConsolidation';
// ---------------------------------------------------------------------------
// Arbitraries
// ---------------------------------------------------------------------------
const arbUsername = fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0);
const arbNoteText = fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0);
const arbDate = fc.integer({ min: 1577836800000, max: 1924905600000 })
.map(ts => new Date(ts).toISOString());
const arbNote = fc.record({
id: fc.integer({ min: 1, max: 100000 }),
queue_item_id: fc.integer({ min: 1, max: 10000 }),
user_id: fc.integer({ min: 1, max: 1000 }),
username: arbUsername,
note_text: arbNoteText,
created_at: arbDate,
});
const arbQueueItem = fc.record({
id: fc.integer({ min: 1, max: 10000 }),
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
finding_title: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
vendor: fc.constantFrom('Microsoft', 'Cisco', 'Juniper', 'Adobe'),
workflow_type: fc.constant('Remediate'),
hostname: fc.constantFrom('server01', 'host-a', 'web-prod'),
ip_address: fc.constantFrom('10.0.0.1', '192.168.1.5', '172.16.0.10'),
cves_json: fc.constant(JSON.stringify(['CVE-2024-1234'])),
});
const arbNonRemediateItem = fc.record({
id: fc.integer({ min: 1, max: 10000 }),
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
finding_title: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
vendor: fc.constantFrom('Microsoft', 'Cisco', 'Juniper', 'Adobe'),
workflow_type: fc.constantFrom('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'),
hostname: fc.constantFrom('server01', 'host-a', 'web-prod'),
ip_address: fc.constantFrom('10.0.0.1', '192.168.1.5', '172.16.0.10'),
cves_json: fc.constant(JSON.stringify(['CVE-2024-5678'])),
});
// ---------------------------------------------------------------------------
// Property 10: Description generation appends remediation notes iff notes exist
// **Validates: Requirements 8.1, 8.2, 8.3**
// ---------------------------------------------------------------------------
describe('Feature: ivanti-queue-remediation, Property 10: Description generation appends remediation notes iff notes exist', () => {
it('appends a "Remediation Notes" section when notesMap has at least one note', () => {
fc.assert(
fc.property(
fc.array(arbQueueItem, { minLength: 1, maxLength: 5 })
.map(items => {
const seen = new Set();
return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; });
})
.filter(items => items.length > 0),
fc.array(arbNote, { minLength: 1, maxLength: 5 }),
(items, notes) => {
const baseDescription = generateConsolidatedDescription(items);
// Map notes to the first item
const notesMap = { [items[0].id]: notes };
const result = appendRemediationNotes(baseDescription, notesMap);
// Should contain the remediation notes section
expect(result).toContain('== Remediation Notes ==');
// Should still contain the base description
expect(result).toContain(baseDescription.trim());
// Each note's text should appear
for (const note of notes) {
expect(result).toContain(note.note_text);
expect(result).toContain(note.username);
}
}
),
{ numRuns: 100 }
);
});
it('notes are listed in chronological order (oldest first) with [YYYY-MM-DD] prefix', () => {
fc.assert(
fc.property(
arbQueueItem,
fc.array(arbNote, { minLength: 2, maxLength: 5 }),
(item, notes) => {
const baseDescription = generateConsolidatedDescription([item]);
const notesMap = { [item.id]: notes };
const result = appendRemediationNotes(baseDescription, notesMap);
// Extract the remediation notes section
const section = result.split('== Remediation Notes ==')[1];
expect(section).toBeDefined();
// Verify each note has the [YYYY-MM-DD] format prefix
for (const note of notes) {
const expectedDate = new Date(note.created_at).toISOString().slice(0, 10);
expect(section).toContain(`[${expectedDate}] ${note.username}:`);
}
// Verify chronological order (oldest first)
const sortedNotes = [...notes].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
let lastIndex = -1;
for (const note of sortedNotes) {
const idx = section.indexOf(note.note_text, lastIndex + 1);
expect(idx).toBeGreaterThan(lastIndex);
lastIndex = idx;
}
}
),
{ numRuns: 100 }
);
});
it('does NOT append a section when notesMap is empty', () => {
fc.assert(
fc.property(
fc.array(arbQueueItem, { minLength: 1, maxLength: 3 })
.map(items => {
const seen = new Set();
return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; });
})
.filter(items => items.length > 0),
(items) => {
const baseDescription = generateConsolidatedDescription(items);
const result = appendRemediationNotes(baseDescription, {});
expect(result).toBe(baseDescription);
expect(result).not.toContain('== Remediation Notes ==');
}
),
{ numRuns: 100 }
);
});
it('does NOT append a section when notesMap has items with empty arrays', () => {
fc.assert(
fc.property(
arbQueueItem,
(item) => {
const baseDescription = generateConsolidatedDescription([item]);
const notesMap = { [item.id]: [] };
const result = appendRemediationNotes(baseDescription, notesMap);
expect(result).toBe(baseDescription);
expect(result).not.toContain('== Remediation Notes ==');
}
),
{ numRuns: 100 }
);
});
});
// ---------------------------------------------------------------------------
// Property 11: Non-Remediate description unchanged
// **Validates: Requirements 8.4**
// ---------------------------------------------------------------------------
describe('Feature: ivanti-queue-remediation, Property 11: Non-Remediate description unchanged', () => {
it('output is identical to generateConsolidatedDescription when no notes exist', () => {
fc.assert(
fc.property(
fc.array(arbNonRemediateItem, { minLength: 1, maxLength: 5 })
.map(items => {
const seen = new Set();
return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; });
})
.filter(items => items.length > 0),
(items) => {
const baseDescription = generateConsolidatedDescription(items);
// Empty notesMap — simulates non-Remediate items
const result = appendRemediationNotes(baseDescription, {});
expect(result).toBe(baseDescription);
expect(result).not.toContain('Remediation Notes');
}
),
{ numRuns: 100 }
);
});
it('output is identical when notesMap is null or undefined', () => {
fc.assert(
fc.property(
fc.array(arbNonRemediateItem, { minLength: 1, maxLength: 3 })
.map(items => {
const seen = new Set();
return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; });
})
.filter(items => items.length > 0),
fc.oneof(fc.constant(null), fc.constant(undefined)),
(items, notesMap) => {
const baseDescription = generateConsolidatedDescription(items);
const result = appendRemediationNotes(baseDescription, notesMap);
expect(result).toBe(baseDescription);
}
),
{ numRuns: 50 }
);
});
});

View File

@@ -0,0 +1,180 @@
/**
* Property-Based Test: Remediate Queue Grouping
*
* Feature: ivanti-queue-remediation
* Property 2: Remediate items grouped into vendor sections, never Inventory
*
* For any queue item with workflow_type "Remediate", the groupQueueItems function
* SHALL place it in a vendor-grouped section (using the item's vendor field, or
* "Unknown" if vendor is empty/null) and SHALL NOT place it in the Inventory section.
*
* **Validates: Requirements 2.1, 2.2, 2.4**
*/
import fc from 'fast-check';
import { groupQueueItems } from '../utils/queueGrouping';
// ---------------------------------------------------------------------------
// Arbitraries
// ---------------------------------------------------------------------------
const arbVendor = fc.oneof(
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
fc.constantFrom('Microsoft', 'Cisco', 'Juniper', 'ADTRAN', 'VMware')
);
const arbEmptyVendor = fc.oneof(
fc.constant(''),
fc.constant(null),
fc.constant(undefined),
fc.constant(' ') // whitespace only
);
const arbRemediateItemWithVendor = fc.record({
id: fc.integer({ min: 1, max: 100000 }),
workflow_type: fc.constant('Remediate'),
vendor: arbVendor,
status: fc.constant('pending'),
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
finding_title: fc.string({ minLength: 1, maxLength: 100 }),
});
const arbRemediateItemNoVendor = fc.record({
id: fc.integer({ min: 1, max: 100000 }),
workflow_type: fc.constant('Remediate'),
vendor: arbEmptyVendor,
status: fc.constant('pending'),
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
finding_title: fc.string({ minLength: 1, maxLength: 100 }),
});
const arbInventoryItem = fc.record({
id: fc.integer({ min: 100001, max: 200000 }),
workflow_type: fc.constantFrom('CARD', 'GRANITE', 'DECOM'),
vendor: fc.constant(''),
status: fc.constant('pending'),
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
finding_title: fc.string({ minLength: 1, maxLength: 100 }),
});
// ---------------------------------------------------------------------------
// Property 2: Remediate items grouped into vendor sections, never Inventory
// ---------------------------------------------------------------------------
describe('Feature: ivanti-queue-remediation, Property 2: Remediate items grouped into vendor sections, never Inventory', () => {
it('Remediate items with a vendor are placed in vendor-grouped sections, never Inventory', () => {
fc.assert(
fc.property(
fc.array(arbRemediateItemWithVendor, { minLength: 1, maxLength: 20 })
.map(items => {
// Ensure unique IDs
const seen = new Set();
return items.filter(item => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
})
.filter(items => items.length > 0),
(items) => {
const sections = groupQueueItems(items);
// No Inventory section should exist (Remediate items never go there)
const inventorySection = sections.find(s => s.type === 'inventory');
expect(inventorySection).toBeUndefined();
// All items should be in vendor sections
const vendorSections = sections.filter(s => s.type === 'vendor');
const allGroupedItems = vendorSections.flatMap(s => s.items);
expect(allGroupedItems.length).toBe(items.length);
// Each item should be in its vendor's section
for (const item of items) {
const expectedVendor = item.vendor?.trim() || 'Unknown';
const section = vendorSections.find(s => s.label === expectedVendor);
expect(section).toBeDefined();
expect(section.items.some(i => i.id === item.id)).toBe(true);
}
}
),
{ numRuns: 100 }
);
});
it('Remediate items with empty/null/whitespace-only vendor land in "Unknown" section', () => {
fc.assert(
fc.property(
fc.array(arbRemediateItemNoVendor, { minLength: 1, maxLength: 10 })
.map(items => {
const seen = new Set();
return items.filter(item => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
})
.filter(items => items.length > 0),
(items) => {
const sections = groupQueueItems(items);
// No Inventory section
const inventorySection = sections.find(s => s.type === 'inventory');
expect(inventorySection).toBeUndefined();
// All items should be in the "Unknown" vendor section
const unknownSection = sections.find(s => s.label === 'Unknown');
expect(unknownSection).toBeDefined();
expect(unknownSection.items.length).toBe(items.length);
}
),
{ numRuns: 100 }
);
});
it('Remediate items are never placed in the Inventory section even when mixed with inventory items', () => {
fc.assert(
fc.property(
fc.array(arbRemediateItemWithVendor, { minLength: 1, maxLength: 10 })
.map(items => {
const seen = new Set();
return items.filter(item => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
})
.filter(items => items.length > 0),
fc.array(arbInventoryItem, { minLength: 1, maxLength: 5 })
.map(items => {
const seen = new Set();
return items.filter(item => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
})
.filter(items => items.length > 0),
(remediateItems, inventoryItems) => {
const allItems = [...remediateItems, ...inventoryItems];
const sections = groupQueueItems(allItems);
// Inventory section exists (from inventory items)
const inventorySection = sections.find(s => s.type === 'inventory');
expect(inventorySection).toBeDefined();
// No Remediate items in the inventory section
const remediateInInventory = inventorySection.items.filter(
i => i.workflow_type === 'Remediate'
);
expect(remediateInInventory.length).toBe(0);
// All Remediate items are in vendor sections
const vendorSections = sections.filter(s => s.type === 'vendor');
const allVendorItems = vendorSections.flatMap(s => s.items);
for (const item of remediateItems) {
expect(allVendorItems.some(i => i.id === item.id)).toBe(true);
}
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,362 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, Loader, AlertCircle, Send, RefreshCw } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Styles — matches dark theme tactical intelligence aesthetic
// ---------------------------------------------------------------------------
const STYLES = {
overlay: {
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
backdrop: {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(4px)',
},
content: {
position: 'relative',
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '16px',
padding: '2rem',
width: '90%',
maxWidth: '560px',
maxHeight: '85vh',
overflowY: 'auto',
zIndex: 101,
},
header: {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: '1.25rem',
},
title: {
fontFamily: 'monospace',
fontSize: '0.8rem',
fontWeight: 700,
color: '#A855F7',
textTransform: 'uppercase',
letterSpacing: '0.1em',
},
subtitle: {
fontFamily: 'monospace',
fontSize: '0.7rem',
color: '#94A3B8',
marginTop: '0.35rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '400px',
},
closeBtn: {
background: 'none',
border: 'none',
color: '#64748B',
cursor: 'pointer',
padding: '0.25rem',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
textarea: {
width: '100%',
boxSizing: 'border-box',
minHeight: '100px',
background: 'rgba(15, 23, 42, 0.8)',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '8px',
padding: '0.75rem',
color: '#F8FAFC',
fontSize: '0.85rem',
fontFamily: 'monospace',
resize: 'vertical',
outline: 'none',
},
charCounter: {
fontFamily: 'monospace',
fontSize: '0.6rem',
color: '#64748B',
textAlign: 'right',
marginTop: '0.25rem',
},
submitBtn: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(168, 85, 247, 0.4)',
background: 'rgba(168, 85, 247, 0.15)',
color: '#C084FC',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
},
submitBtnDisabled: {
opacity: 0.4,
cursor: 'not-allowed',
},
noteItem: {
padding: '0.75rem',
marginBottom: '0.5rem',
background: 'rgba(14, 165, 233, 0.04)',
border: '1px solid rgba(14, 165, 233, 0.1)',
borderRadius: '8px',
},
noteMeta: {
fontFamily: 'monospace',
fontSize: '0.6rem',
color: '#64748B',
marginBottom: '0.35rem',
display: 'flex',
gap: '0.5rem',
},
noteText: {
fontFamily: 'monospace',
fontSize: '0.75rem',
color: '#CBD5E1',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
error: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '0.5rem',
marginBottom: '0.75rem',
},
errorText: {
fontFamily: 'monospace',
fontSize: '0.7rem',
color: '#FCA5A5',
},
emptyState: {
textAlign: 'center',
padding: '1.5rem 0',
fontFamily: 'monospace',
fontSize: '0.7rem',
color: '#475569',
},
divider: {
height: '1px',
background: 'rgba(14, 165, 233, 0.1)',
margin: '1rem 0',
},
retryBtn: {
padding: '0.4rem 0.75rem',
borderRadius: '6px',
border: '1px solid rgba(14, 165, 233, 0.3)',
background: 'rgba(14, 165, 233, 0.1)',
color: '#7DD3FC',
cursor: 'pointer',
fontSize: '0.7rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.3rem',
marginTop: '0.5rem',
},
};
// ---------------------------------------------------------------------------
// RemediationModal — add and view remediation notes for a queue item
// ---------------------------------------------------------------------------
export default function RemediationModal({ item, onClose, onNoteAdded }) {
const [notes, setNotes] = useState([]);
const [newNoteText, setNewNoteText] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [fetchError, setFetchError] = useState(null);
// ---------------------------------------------------------------------------
// Fetch existing notes on mount
// ---------------------------------------------------------------------------
const fetchNotes = useCallback(async () => {
setLoading(true);
setFetchError(null);
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, {
credentials: 'include',
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
const data = await res.json();
setNotes(data);
} catch (e) {
setFetchError(e.message || 'Failed to load notes.');
} finally {
setLoading(false);
}
}, [item.id]);
useEffect(() => {
fetchNotes();
}, [fetchNotes]);
// ---------------------------------------------------------------------------
// Submit new note
// ---------------------------------------------------------------------------
const handleSubmit = useCallback(async () => {
if (!newNoteText.trim() || saving) return;
setSaving(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ note_text: newNoteText }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || `HTTP ${res.status}`);
}
// Prepend new note to list (most recent first)
setNotes((prev) => [data, ...prev]);
setNewNoteText('');
if (onNoteAdded) onNoteAdded();
} catch (e) {
setError(e.message || 'Failed to save note.');
} finally {
setSaving(false);
}
}, [newNoteText, saving, item.id, onNoteAdded]);
// ---------------------------------------------------------------------------
// Format date as YYYY-MM-DD
// ---------------------------------------------------------------------------
const formatDate = (dateStr) => {
try {
const d = new Date(dateStr);
return d.toISOString().slice(0, 10);
} catch {
return '—';
}
};
const canSubmit = newNoteText.trim().length > 0 && !saving;
const remaining = 5000 - newNoteText.length;
return (
<div style={STYLES.overlay}>
<div style={STYLES.backdrop} onClick={onClose} />
<div style={STYLES.content}>
{/* Header */}
<div style={STYLES.header}>
<div>
<div style={STYLES.title}>Remediation Notes</div>
<div style={STYLES.subtitle} title={item.finding_title || item.finding_id}>
{item.finding_title || item.finding_id}
</div>
<div style={{ ...STYLES.subtitle, fontSize: '0.6rem', color: '#64748B' }}>
ID: {item.finding_id}
</div>
</div>
<button onClick={onClose} style={STYLES.closeBtn} aria-label="Close modal">
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
{/* New note input */}
<div style={{ marginBottom: '1rem' }}>
<textarea
value={newNoteText}
onChange={(e) => setNewNoteText(e.target.value)}
maxLength={5000}
placeholder="Describe what remediation steps were taken…"
style={STYLES.textarea}
disabled={saving}
/>
<div style={STYLES.charCounter}>
{remaining} characters remaining
</div>
</div>
{/* Error message */}
{error && (
<div style={STYLES.error}>
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
<span style={STYLES.errorText}>{error}</span>
</div>
)}
{/* Submit button */}
<div style={{ marginBottom: '1rem' }}>
<button
onClick={handleSubmit}
disabled={!canSubmit}
style={{
...STYLES.submitBtn,
...(canSubmit ? {} : STYLES.submitBtnDisabled),
}}
>
<Send style={{ width: '14px', height: '14px' }} />
{saving ? 'Saving...' : 'Add Note'}
</button>
</div>
<div style={STYLES.divider} />
{/* Notes list */}
{loading && (
<div style={{ textAlign: 'center', padding: '1.5rem 0' }}>
<Loader style={{ width: '20px', height: '20px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto' }} />
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569', marginTop: '0.5rem' }}>
Loading notes...
</div>
</div>
)}
{fetchError && !loading && (
<div style={STYLES.error}>
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
<span style={STYLES.errorText}>{fetchError}</span>
<button onClick={fetchNotes} style={STYLES.retryBtn}>
<RefreshCw style={{ width: '12px', height: '12px' }} />
Retry
</button>
</div>
)}
{!loading && !fetchError && notes.length === 0 && (
<div style={STYLES.emptyState}>
No remediation notes yet.
</div>
)}
{!loading && !fetchError && notes.length > 0 && (
<div>
{notes.map((note) => (
<div key={note.id} style={STYLES.noteItem}>
<div style={STYLES.noteMeta}>
<span style={{ color: '#A855F7', fontWeight: 600 }}>{note.username}</span>
<span>{formatDate(note.created_at)}</span>
</div>
<div style={STYLES.noteText}>{note.note_text}</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

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>
);
}

View File

@@ -1468,6 +1468,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
{ 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 (

View File

@@ -90,3 +90,53 @@ export function extractCommonVendor(items) {
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
return vendors.length === 1 ? vendors[0] : '';
}
/**
* Append remediation notes to a Jira ticket description.
* Only appends if notesMap contains notes for at least one item.
*
* @param {string} baseDescription - The standard consolidated description
* @param {Object} notesMap - { [queue_item_id]: Array<{username, note_text, created_at}> }
* @returns {string} Description with remediation notes appended (or unchanged)
*/
export function appendRemediationNotes(baseDescription, notesMap) {
if (!notesMap || typeof notesMap !== 'object') return baseDescription;
// Collect all notes from all items, sorted chronologically (oldest first)
const allNotes = [];
for (const [_itemId, notes] of Object.entries(notesMap)) {
if (!Array.isArray(notes)) continue;
for (const note of notes) {
if (note && note.note_text) {
allNotes.push(note);
}
}
}
if (allNotes.length === 0) return baseDescription;
// Sort chronologically (oldest first)
allNotes.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
let section = '\n== Remediation Notes ==\n';
for (const note of allNotes) {
const date = formatNoteDate(note.created_at);
section += `[${date}] ${note.username}: ${note.note_text}\n`;
}
return baseDescription + section;
}
/**
* Format a date string as YYYY-MM-DD for note display.
* @param {string} dateStr - ISO date string
* @returns {string} Formatted date
*/
function formatNoteDate(dateStr) {
try {
const d = new Date(dateStr);
return d.toISOString().slice(0, 10);
} catch {
return 'Unknown';
}
}