feat(reporting): add Ivanti queue panel for batch FP/Archer staging

Adds a persistent per-user staging queue so analysts can tag findings
during review and batch-process Ivanti workflows in one focused session.

Backend:
- New ivanti_todo_queue table (user-scoped, vendor, workflow_type, status)
- Table auto-created on server startup via idempotent CREATE IF NOT EXISTS
- New route /api/ivanti/todo-queue: GET, POST, PUT/:id, DELETE/:id,
  DELETE/completed — all scoped to req.user.id

Frontend (ReportingPage):
- Fixed checkbox column on findings table; clicking opens an add-to-queue
  popover (portal) with vendor input and FP/Archer toggle
- Already-queued rows show checked/disabled checkbox
- Queue slide-out panel (420px fixed, CSS transition) with items grouped
  by vendor, per-item complete toggle + delete, Clear Completed footer
- Queue button in header with live pending-count badge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 14:10:53 -06:00
parent 1520cc994b
commit 887d11610e
4 changed files with 828 additions and 5 deletions

View File

@@ -0,0 +1,43 @@
// Migration: Add ivanti_todo_queue table
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting ivanti_todo_queue migration...');
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_todo_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
finding_id TEXT NOT NULL,
finding_title TEXT,
cves_json TEXT,
vendor TEXT NOT NULL,
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`, (err) => {
if (err) console.error('Error creating table:', err);
else console.log('✓ ivanti_todo_queue table created');
});
db.run(
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
(err) => {
if (err) console.error('Error creating index:', err);
else console.log('✓ User+status index created');
}
);
console.log('✓ Migration statements queued');
});
db.close(() => {
console.log('Migration complete!');
});

View File

@@ -0,0 +1,208 @@
// routes/ivantiTodoQueue.js
const express = require('express');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer'];
const VALID_STATUSES = ['pending', 'complete'];
function isValidVendor(vendor) {
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
}
function createIvantiTodoQueueRouter(db, requireAuth) {
const router = express.Router();
// GET /api/ivanti/todo-queue
// Fetch current user's queue items, ordered by vendor then created_at
router.get('/', requireAuth(db), (req, res) => {
db.all(
`SELECT * FROM ivanti_todo_queue
WHERE user_id = ?
ORDER BY vendor ASC, created_at ASC`,
[req.user.id],
(err, rows) => {
if (err) {
console.error('Error fetching todo queue:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
// Parse cves_json back to array for each row
const parsed = rows.map((r) => ({
...r,
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
}));
res.json(parsed);
}
);
});
// POST /api/ivanti/todo-queue
// Add a finding to the queue
router.post('/', requireAuth(db), (req, res) => {
const { finding_id, finding_title, cves, vendor, workflow_type } = req.body;
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
return res.status(400).json({ error: 'finding_id is required.' });
}
if (!isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor is required (max 200 chars).' });
}
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP or Archer.' });
}
const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null;
const title = finding_title && typeof finding_title === 'string'
? finding_title.slice(0, 500)
: null;
db.run(
`INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?)`,
[req.user.id, finding_id.trim(), title, cvesJson, vendor.trim(), workflow_type],
function (err) {
if (err) {
console.error('Error adding to queue:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
db.get(
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
[this.lastID],
(err2, row) => {
if (err2 || !row) {
return res.status(201).json({ id: this.lastID, message: 'Added to queue.' });
}
res.status(201).json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
}
);
}
);
});
// PUT /api/ivanti/todo-queue/:id
// Update vendor, workflow_type, or status — scoped to current user
router.put('/:id', requireAuth(db), (req, res) => {
const { id } = req.params;
const { vendor, workflow_type, status } = req.body;
if (vendor !== undefined && !isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' });
}
if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP or Archer.' });
}
if (status !== undefined && !VALID_STATUSES.includes(status)) {
return res.status(400).json({ error: 'status must be pending or complete.' });
}
db.get(
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
[id, req.user.id],
(err, existing) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!existing) {
return res.status(404).json({ error: 'Queue item not found.' });
}
const updates = [];
const params = [];
if (vendor !== undefined) {
updates.push('vendor = ?');
params.push(vendor.trim());
}
if (workflow_type !== undefined) {
updates.push('workflow_type = ?');
params.push(workflow_type);
}
if (status !== undefined) {
updates.push('status = ?');
params.push(status);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update.' });
}
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(id, req.user.id);
db.run(
`UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
params,
function (err2) {
if (err2) {
console.error(err2);
return res.status(500).json({ error: 'Internal server error.' });
}
db.get(
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
[id],
(err3, row) => {
if (err3 || !row) {
return res.json({ message: 'Queue item updated.' });
}
res.json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
}
);
}
);
}
);
});
// DELETE /api/ivanti/todo-queue/completed
// Bulk-delete all completed items for the current user
// IMPORTANT: This route must be registered BEFORE DELETE /:id
router.delete('/completed', requireAuth(db), (req, res) => {
db.run(
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
[req.user.id],
function (err) {
if (err) {
console.error('Error clearing completed queue items:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
res.json({ message: 'Completed items cleared.', deleted: this.changes });
}
);
});
// DELETE /api/ivanti/todo-queue/:id
// Delete a single item — scoped to current user
router.delete('/:id', requireAuth(db), (req, res) => {
const { id } = req.params;
db.get(
'SELECT id FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
[id, req.user.id],
(err, row) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!row) {
return res.status(404).json({ error: 'Queue item not found.' });
}
db.run(
'DELETE FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
[id, req.user.id],
function (err2) {
if (err2) {
console.error(err2);
return res.status(500).json({ error: 'Internal server error.' });
}
res.json({ message: 'Queue item deleted.' });
}
);
}
);
});
return router;
}
module.exports = createIvantiTodoQueueRouter;

View File

@@ -22,6 +22,7 @@ const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
const createArcherTicketsRouter = require('./routes/archerTickets'); const createArcherTicketsRouter = require('./routes/archerTickets');
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows'); const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
const createIvantiFindingsRouter = require('./routes/ivantiFindings'); const createIvantiFindingsRouter = require('./routes/ivantiFindings');
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@@ -123,8 +124,34 @@ app.use('/uploads', express.static('uploads', {
// Database connection // Database connection
const db = new sqlite3.Database('./cve_database.db', (err) => { const db = new sqlite3.Database('./cve_database.db', (err) => {
if (err) console.error('Database connection error:', err); if (err) {
else console.log('Connected to CVE database'); console.error('Database connection error:', err);
return;
}
console.log('Connected to CVE database');
// Ensure ivanti_todo_queue table exists (idempotent migration)
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_todo_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
finding_id TEXT NOT NULL,
finding_title TEXT,
cves_json TEXT,
vendor TEXT NOT NULL,
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`, (err2) => {
if (err2) console.error('Failed to create ivanti_todo_queue table:', err2);
else db.run(
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
(err3) => { if (err3) console.error('Failed to create todo_queue index:', err3); }
);
});
}); });
// Auth routes (public) // Auth routes (public)
@@ -187,6 +214,9 @@ app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
// Ivanti / RiskSense host findings routes (all authenticated users) // Ivanti / RiskSense host findings routes (all authenticated users)
app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth)); app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
// Ivanti queue routes — per-user staging queue for FP / Archer workflows
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
// ========== CVE ENDPOINTS ========== // ========== CVE ENDPOINTS ==========
// Get all CVEs with optional filters (authenticated users) // Get all CVEs with optional filters (authenticated users)

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw } from 'lucide-react'; import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
@@ -1074,6 +1074,363 @@ function TableCell({ colKey, finding, canWrite }) {
} }
} }
// ---------------------------------------------------------------------------
// AddToQueuePopover — portal-based popover for adding a finding to the queue
// ---------------------------------------------------------------------------
function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd, onCancel }) {
const panelRef = useRef(null);
const inputRef = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
useEffect(() => {
if (!anchorRect) return;
setPos({ top: anchorRect.bottom + 6, left: anchorRect.left });
setTimeout(() => inputRef.current?.focus(), 0);
}, [anchorRect]);
// Close on outside click
useEffect(() => {
const handler = (e) => {
if (panelRef.current && !panelRef.current.contains(e.target)) onCancel();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onCancel]);
// Close on Escape
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onCancel(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onCancel]);
const canSubmit = queueForm.vendor.trim().length > 0;
return ReactDOM.createPortal(
<div
ref={panelRef}
style={{
position: 'fixed', top: pos.top, left: pos.left,
width: '260px', zIndex: 9999,
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: '1px solid rgba(14,165,233,0.35)',
borderRadius: '0.5rem',
boxShadow: '0 8px 32px rgba(0,0,0,0.8)',
padding: '0.875rem',
}}
>
{/* Header */}
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem', paddingBottom: '0.5rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
Add to Ivanti Queue
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#94A3B8', marginBottom: '0.75rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.id}>
{finding.id}
</div>
{/* Vendor input */}
<label style={{ display: 'block', marginBottom: '0.625rem' }}>
<span style={{ display: 'block', fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.3rem' }}>
Vendor / Platform
</span>
<input
ref={inputRef}
type="text"
value={queueForm.vendor}
onChange={(e) => setQueueForm((f) => ({ ...f, vendor: e.target.value }))}
placeholder="Juniper, Cisco, ADTRAN…"
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.35rem 0.5rem',
color: '#CBD5E1', fontSize: '0.78rem',
fontFamily: 'monospace', outline: 'none',
}}
onKeyDown={(e) => { if (e.key === 'Enter' && canSubmit) onAdd(); }}
/>
</label>
{/* Workflow type toggle */}
<div style={{ marginBottom: '0.875rem' }}>
<span style={{ display: 'block', fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.3rem' }}>
Workflow Type
</span>
<div style={{ display: 'flex', gap: '0.375rem' }}>
{['FP', 'Archer'].map((wt) => {
const active = queueForm.workflowType === wt;
const col = wt === 'FP' ? '#F59E0B' : '#0EA5E9';
return (
<button
key={wt}
onClick={() => setQueueForm((f) => ({ ...f, workflowType: wt }))}
style={{
flex: 1, padding: '0.3rem',
background: active ? `rgba(${wt === 'FP' ? '245,158,11' : '14,165,233'},0.15)` : 'transparent',
border: `1px solid ${active ? col : 'rgba(255,255,255,0.1)'}`,
borderRadius: '0.25rem',
color: active ? col : '#475569',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
cursor: 'pointer', transition: 'all 0.12s',
}}
>
{wt}
</button>
);
})}
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button
onClick={onAdd}
disabled={!canSubmit}
style={{
flex: 1, padding: '0.4rem',
background: canSubmit ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.05)',
border: `1px solid ${canSubmit ? 'rgba(14,165,233,0.4)' : 'rgba(14,165,233,0.1)'}`,
borderRadius: '0.25rem',
color: canSubmit ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
cursor: canSubmit ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
Add to Queue
</button>
<button
onClick={onCancel}
style={{
padding: '0.4rem 0.625rem',
background: 'none', border: 'none',
color: '#475569', fontFamily: 'monospace', fontSize: '0.72rem',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>,
document.body
);
}
// ---------------------------------------------------------------------------
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
// ---------------------------------------------------------------------------
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onClearCompleted }) {
const pendingCount = items.filter((i) => i.status === 'pending').length;
const completedCount = items.filter((i) => i.status === 'complete').length;
// Group by vendor, sorted alphabetically
const grouped = useMemo(() => {
const map = {};
items.forEach((item) => {
const v = item.vendor || 'Unknown';
if (!map[v]) map[v] = [];
map[v].push(item);
});
return Object.keys(map).sort().map((vendor) => ({ vendor, items: map[vendor] }));
}, [items]);
return (
<>
{/* Backdrop */}
{open && (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.45)',
zIndex: 9998,
}}
/>
)}
{/* Panel */}
<div
style={{
position: 'fixed', top: 0, right: 0,
height: '100vh', width: '420px',
zIndex: 9999,
display: 'flex', flexDirection: 'column',
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
borderLeft: '1px solid rgba(14,165,233,0.2)',
boxShadow: '-8px 0 40px rgba(0,0,0,0.7)',
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 0.25s cubic-bezier(0.4,0,0.2,1)',
}}
>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.25rem',
borderBottom: '1px solid rgba(14,165,233,0.15)',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<ListTodo style={{ width: '18px', height: '18px', color: '#0EA5E9' }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: '#E2E8F0', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
Ivanti Queue
</span>
{pendingCount > 0 && (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: '20px', height: '20px', padding: '0 5px',
background: 'rgba(14,165,233,0.2)',
border: '1px solid rgba(14,165,233,0.4)',
borderRadius: '999px',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700', color: '#0EA5E9',
}}>
{pendingCount}
</span>
)}
</div>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}
>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0.75rem 1.25rem' }}>
{items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 0', fontFamily: 'monospace', fontSize: '0.75rem', color: '#334155' }}>
No items in queue.<br />
<span style={{ fontSize: '0.68rem', color: '#1E293B', marginTop: '0.5rem', display: 'block' }}>
Check a row in the findings table to add it.
</span>
</div>
) : grouped.map(({ vendor, items: vendorItems }) => (
<div key={vendor} style={{ marginBottom: '1.25rem' }}>
{/* Vendor group header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.3rem 0', marginBottom: '0.375rem',
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
{vendor}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
{vendorItems.length}
</span>
</div>
{/* Items */}
{vendorItems.map((item) => {
const done = item.status === 'complete';
const isFP = item.workflow_type === 'FP';
const cves = item.cves || [];
const cveDisplay = cves.length > 0
? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '')
: '—';
return (
<div
key={item.id}
style={{
display: 'flex', alignItems: 'flex-start', gap: '0.5rem',
padding: '0.5rem 0.625rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: done ? 'rgba(16,185,129,0.04)' : 'rgba(14,165,233,0.04)',
border: `1px solid ${done ? 'rgba(16,185,129,0.12)' : 'rgba(14,165,233,0.1)'}`,
opacity: done ? 0.55 : 1,
transition: 'opacity 0.15s',
}}
>
{/* Complete checkbox */}
<input
type="checkbox"
checked={done}
onChange={() => onUpdate(item.id, { status: done ? 'pending' : 'complete' })}
style={{ accentColor: '#10B981', width: '14px', height: '14px', flexShrink: 0, marginTop: '2px', cursor: 'pointer' }}
/>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
color: done ? '#475569' : '#CBD5E1',
textDecoration: done ? 'line-through' : 'none',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={item.finding_id}>
{item.finding_id}
</div>
{cves.length > 0 && (
<div style={{
fontFamily: 'monospace', fontSize: '0.62rem',
color: done ? '#334155' : '#64748B',
textDecoration: done ? 'line-through' : 'none',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
marginTop: '1px',
}} title={cves.join(', ')}>
{cveDisplay}
</div>
)}
</div>
{/* Workflow type badge */}
<span style={{
flexShrink: 0,
padding: '0.1rem 0.35rem',
borderRadius: '0.2rem',
background: isFP ? 'rgba(245,158,11,0.12)' : 'rgba(14,165,233,0.12)',
border: `1px solid ${isFP ? 'rgba(245,158,11,0.3)' : 'rgba(14,165,233,0.3)'}`,
color: isFP ? '#F59E0B' : '#0EA5E9',
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
}}>
{item.workflow_type}
</span>
{/* Delete button */}
<button
onClick={() => onDelete(item.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
onMouseEnter={(e) => e.currentTarget.style.color = '#EF4444'}
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
title="Remove from queue"
>
<Trash2 style={{ width: '13px', height: '13px' }} />
</button>
</div>
);
})}
</div>
))}
</div>
{/* Footer */}
<div style={{
padding: '0.75rem 1.25rem',
borderTop: '1px solid rgba(255,255,255,0.06)',
flexShrink: 0,
}}>
<button
onClick={onClearCompleted}
disabled={completedCount === 0}
style={{
width: '100%', padding: '0.45rem',
background: completedCount > 0 ? 'rgba(16,185,129,0.08)' : 'transparent',
border: `1px solid ${completedCount > 0 ? 'rgba(16,185,129,0.25)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.375rem',
color: completedCount > 0 ? '#10B981' : '#334155',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: completedCount > 0 ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
Clear Completed {completedCount > 0 ? `(${completedCount})` : ''}
</button>
</div>
</div>
</>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main ReportingPage // Main ReportingPage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1174,6 +1531,7 @@ export default function ReportingPage({ filterDate, filterEXC }) {
fetchFindings(); fetchFindings();
fetchCounts(); fetchCounts();
fetchFPWorkflowCounts(); fetchFPWorkflowCounts();
fetchQueue();
}, []); // eslint-disable-line }, []); // eslint-disable-line
// Set/clear a single column filter // Set/clear a single column filter
@@ -1250,6 +1608,103 @@ export default function ReportingPage({ filterDate, filterEXC }) {
const activeFilterCount = Object.keys(columnFilters).length + (actionFilter ? 1 : 0) + (excFilter ? 1 : 0); const activeFilterCount = Object.keys(columnFilters).length + (actionFilter ? 1 : 0) + (excFilter ? 1 : 0);
// Queue state
const [queueItems, setQueueItems] = useState([]);
const [queueOpen, setQueueOpen] = useState(false);
const [queueLoading, setQueueLoading] = useState(false);
const [addPopover, setAddPopover] = useState(null); // { finding, anchorRect }
const [queueForm, setQueueForm] = useState({ vendor: '', workflowType: 'FP' });
// Queue API helpers
const fetchQueue = useCallback(async () => {
setQueueLoading(true);
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue`, { credentials: 'include' });
const data = await res.json();
if (res.ok) setQueueItems(data);
} catch (e) {
console.error('Error fetching queue:', e);
} finally {
setQueueLoading(false);
}
}, []);
const addToQueue = useCallback(async () => {
if (!addPopover) return;
const { finding } = addPopover;
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
finding_id: finding.id,
finding_title: finding.title || null,
cves: finding.cves || [],
vendor: queueForm.vendor.trim(),
workflow_type: queueForm.workflowType,
}),
});
const data = await res.json();
if (res.ok) {
setQueueItems((prev) => [...prev, data].sort((a, b) =>
a.vendor.localeCompare(b.vendor) || a.id - b.id
));
}
} catch (e) {
console.error('Error adding to queue:', e);
}
setAddPopover(null);
setQueueForm({ vendor: '', workflowType: 'FP' });
}, [addPopover, queueForm]);
const updateQueueItem = useCallback(async (id, changes) => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
});
const data = await res.json();
if (res.ok) {
setQueueItems((prev) => prev.map((item) => item.id === id ? data : item));
}
} catch (e) {
console.error('Error updating queue item:', e);
}
}, []);
const deleteQueueItem = useCallback(async (id) => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (res.ok) setQueueItems((prev) => prev.filter((item) => item.id !== id));
} catch (e) {
console.error('Error deleting queue item:', e);
}
}, []);
const clearCompleted = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/completed`, {
method: 'DELETE',
credentials: 'include',
});
if (res.ok) setQueueItems((prev) => prev.filter((item) => item.status !== 'complete'));
} catch (e) {
console.error('Error clearing completed queue items:', e);
}
}, []);
const isQueued = useCallback((findingId) =>
queueItems.some((item) => item.finding_id === findingId),
[queueItems]);
const pendingQueueCount = queueItems.filter((i) => i.status === 'pending').length;
const [exportMenuOpen, setExportMenuOpen] = useState(false); const [exportMenuOpen, setExportMenuOpen] = useState(false);
const exportBtnRef = useRef(null); const exportBtnRef = useRef(null);
@@ -1540,6 +1995,35 @@ export default function ReportingPage({ filterDate, filterEXC }) {
</div> </div>
)} )}
</div> </div>
{/* Queue button */}
<button
onClick={() => setQueueOpen((o) => !o)}
style={{
position: 'relative',
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: queueOpen ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.08)',
border: `1px solid rgba(14,165,233,${queueOpen ? '0.5' : '0.25'})`,
borderRadius: '0.375rem',
color: '#0EA5E9', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
<ListTodo style={{ width: '13px', height: '13px' }} />
Queue
{pendingQueueCount > 0 && (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: '16px', height: '16px', padding: '0 4px',
background: '#0EA5E9', borderRadius: '999px',
fontFamily: 'monospace', fontSize: '0.58rem', fontWeight: '700', color: '#0A1628',
marginLeft: '1px',
}}>
{pendingQueueCount}
</span>
)}
</button>
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} /> <ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
<button <button
onClick={syncFindings} onClick={syncFindings}
@@ -1585,6 +2069,15 @@ export default function ReportingPage({ filterDate, filterEXC }) {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
<thead> <thead>
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}> <tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
{/* Fixed checkbox column — not part of column manager */}
<th
style={{
width: '36px', minWidth: '36px', padding: '0.5rem 0.5rem',
background: 'rgb(10, 20, 36)',
position: 'sticky', top: 0, zIndex: 10,
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
}}
/>
{visibleCols.map((col) => { {visibleCols.map((col) => {
const def = COLUMN_DEFS[col.key]; const def = COLUMN_DEFS[col.key];
const active = sort.field === col.key; const active = sort.field === col.key;
@@ -1636,7 +2129,8 @@ export default function ReportingPage({ filterDate, filterEXC }) {
</thead> </thead>
<tbody> <tbody>
{sorted.map((finding, idx) => { {sorted.map((finding, idx) => {
const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)'; const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)';
const queued = isQueued(finding.id);
return ( return (
<tr <tr
key={finding.id} key={finding.id}
@@ -1644,6 +2138,29 @@ export default function ReportingPage({ filterDate, filterEXC }) {
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'} onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.background = rowBg} onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
> >
{/* Checkbox cell */}
<td
style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}
onClick={(e) => {
if (queued) return;
const rect = e.currentTarget.getBoundingClientRect();
setAddPopover({ finding, anchorRect: rect });
setQueueForm({ vendor: '', workflowType: 'FP' });
}}
>
<input
type="checkbox"
readOnly
checked={queued}
disabled={queued}
style={{
accentColor: '#0EA5E9',
width: '13px', height: '13px',
cursor: queued ? 'default' : 'pointer',
pointerEvents: 'none',
}}
/>
</td>
{visibleCols.map((col) => ( {visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} /> <TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} />
))} ))}
@@ -1652,7 +2169,7 @@ export default function ReportingPage({ filterDate, filterEXC }) {
})} })}
{sorted.length === 0 && ( {sorted.length === 0 && (
<tr> <tr>
<td colSpan={visibleCols.length} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}> <td colSpan={visibleCols.length + 1} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'} {activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
</td> </td>
</tr> </tr>
@@ -1674,6 +2191,31 @@ export default function ReportingPage({ filterDate, filterEXC }) {
onClose={() => setOpenFilter(null)} onClose={() => setOpenFilter(null)}
/> />
)} )}
{/* Add-to-queue popover — portal */}
{addPopover && (
<AddToQueuePopover
finding={addPopover.finding}
anchorRect={addPopover.anchorRect}
queueForm={queueForm}
setQueueForm={setQueueForm}
onAdd={addToQueue}
onCancel={() => {
setAddPopover(null);
setQueueForm({ vendor: '', workflowType: 'FP' });
}}
/>
)}
{/* Queue panel — fixed slide-out */}
<QueuePanel
open={queueOpen}
items={queueItems}
onClose={() => setQueueOpen(false)}
onUpdate={updateQueueItem}
onDelete={deleteQueueItem}
onClearCompleted={clearCompleted}
/>
</div> </div>
); );
} }