6 Commits

Author SHA1 Message Date
b0adfa1bda feat(reporting): Ivanti queue panel for batch FP/Archer/CARD staging
Adds a persistent per-user staging queue on the Reporting page so
analysts can tag findings during review and batch-process Ivanti
workflows in one focused session.

Features:
- Checkbox column on findings table to tag rows into the queue
- Add-to-queue popover: vendor input, FP / Archer / CARD workflow toggle
  (CARD skips vendor requirement and stores IP address instead)
- Queue slide-out panel (420px, CSS transition) with items grouped by
  vendor; CARD items are their own top section showing IP address
- Per-item complete toggle, individual delete, and multi-select bulk delete
- Clear Completed footer button
- Queue button in header with live pending-count badge
- All data DB-backed (ivanti_todo_queue table, per-user scoped)
- Popover flips above row when near bottom of viewport

Migrations required on existing DBs:
  node backend/migrations/add_ivanti_todo_queue_table.js   (or let server auto-create)
  node backend/migrations/add_card_workflow_type.js
  node backend/migrations/add_todo_queue_ip_address.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 16:08:21 -06:00
7a2c56a11f fix(reporting): visible queue checkbox + multi-select delete
Table: removed disabled={queued} from the row checkbox so accentColor
renders properly — checked rows now show a solid blue tick instead of
the greyed-out browser default.

Queue panel: each item now has a small red selection checkbox (opacity
0.35 when idle, full when selected). Selecting any items reveals a red
'Delete (N)' button in the footer alongside 'Clear Completed'. Bulk
deletes run in parallel; selection state is automatically pruned when
items are removed via the individual trash button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 15:43:43 -06:00
89b1f57ef4 feat(reporting): store and display IP address on CARD queue items
Adds ip_address column to ivanti_todo_queue so CARD entries carry the
host IP needed to locate the asset in CARD.

- Migration: ALTER TABLE ADD COLUMN ip_address TEXT (safe to re-run)
- Backend: accepts ip_address in POST body, stores up to 64 chars
- Frontend: captures finding.ipAddress when adding to queue; CARD items
  in the queue panel show the IP in green instead of the CVE list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 15:01:32 -06:00
6bf6371e51 feat(reporting): CARD workflow needs no vendor + own queue section
CARD workflow type no longer requires a vendor/platform entry since
asset disposition is handled entirely within CARD. In the popover the
vendor field is replaced with a note when CARD is selected, and the
Add button is enabled immediately.

In the queue panel, CARD items are separated into their own top section
(green header) rather than being mixed into vendor groups.

Backend validation updated to skip vendor requirement for CARD.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:52:06 -06:00
4d472b0aef fix(reporting): smart-flip queue popover + add CARD workflow type
Popover now flips above the row when it would overflow the bottom of the
viewport, and clamps horizontally to stay within the window.

Adds CARD as a third workflow type (for out-of-team asset disposition in
CARD) alongside FP and Archer. CARD is styled in green (#10B981) across
the popover toggle and queue panel badge.

DB: new migration (add_card_workflow_type.js) recreates ivanti_todo_queue
with an updated CHECK constraint to allow 'CARD'; run manually on dev.
App-level validation in the route is updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:46:59 -06:00
887d11610e 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>
2026-03-26 14:10:53 -06:00
6 changed files with 1053 additions and 5 deletions

View File

@@ -0,0 +1,78 @@
// Migration: Add CARD to workflow_type CHECK constraint on ivanti_todo_queue
// SQLite cannot ALTER a CHECK constraint, so this recreates the 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 add_card_workflow_type migration...');
db.serialize(() => {
db.run('PRAGMA foreign_keys = OFF', (err) => {
if (err) console.error('PRAGMA error:', err);
});
db.run('BEGIN TRANSACTION', (err) => {
if (err) { console.error('BEGIN error:', err); return; }
});
db.run(`
CREATE TABLE ivanti_todo_queue_new (
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', 'CARD')),
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 new table:', err);
else console.log('✓ ivanti_todo_queue_new created');
});
db.run(
'INSERT INTO ivanti_todo_queue_new SELECT * FROM ivanti_todo_queue',
(err) => {
if (err) console.error('Error copying data:', err);
else console.log('✓ Data copied');
}
);
db.run('DROP TABLE ivanti_todo_queue', (err) => {
if (err) console.error('Error dropping old table:', err);
else console.log('✓ Old table dropped');
});
db.run(
'ALTER TABLE ivanti_todo_queue_new RENAME TO ivanti_todo_queue',
(err) => {
if (err) console.error('Error renaming table:', err);
else console.log('✓ Table renamed');
}
);
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('✓ Index recreated');
}
);
db.run('COMMIT', (err) => {
if (err) console.error('COMMIT error:', err);
else console.log('✓ Transaction committed');
});
db.run('PRAGMA foreign_keys = ON', () => {});
});
db.close(() => {
console.log('Migration complete!');
});

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,25 @@
// Migration: Add ip_address column to ivanti_todo_queue
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 add_todo_queue_ip_address migration...');
db.run(
'ALTER TABLE ivanti_todo_queue ADD COLUMN ip_address TEXT',
(err) => {
if (err) {
// Column may already exist if migration was run before
if (err.message.includes('duplicate column name')) {
console.log('✓ ip_address column already exists, skipping');
} else {
console.error('Error adding column:', err);
}
} else {
console.log('✓ ip_address column added');
}
db.close(() => console.log('Migration complete!'));
}
);

View File

@@ -0,0 +1,214 @@
// routes/ivantiTodoQueue.js
const express = require('express');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
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, ip_address, 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 (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' });
}
// Vendor is required for FP and Archer, optional for CARD
if (workflow_type !== 'CARD' && !isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
}
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
}
const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim();
const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null;
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : 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, ip_address, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, vendorVal, 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,35 @@ 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,
ip_address TEXT,
vendor TEXT NOT NULL,
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
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 +215,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,465 @@ 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;
const PANEL_W = 260;
const PANEL_H = 360; // conservative estimate (3 workflow buttons)
const spaceBelow = window.innerHeight - anchorRect.bottom - 6;
const top = spaceBelow >= PANEL_H
? anchorRect.bottom + 6
: Math.max(8, anchorRect.top - PANEL_H - 6);
const left = Math.min(anchorRect.left, window.innerWidth - PANEL_W - 8);
setPos({ top, 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 isCard = queueForm.workflowType === 'CARD';
const canSubmit = isCard || 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 — hidden for CARD */}
{isCard ? (
<div style={{
marginBottom: '0.625rem', padding: '0.4rem 0.5rem',
background: 'rgba(16,185,129,0.06)',
border: '1px solid rgba(16,185,129,0.2)',
borderRadius: '0.25rem',
fontFamily: 'monospace', fontSize: '0.68rem', color: '#10B981',
}}>
No vendor required disposition handled in CARD
</div>
) : (
<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' }}>
{[
{ key: 'FP', col: '#F59E0B', rgb: '245,158,11' },
{ key: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
{ key: 'CARD', col: '#10B981', rgb: '16,185,129' },
].map(({ key, col, rgb }) => {
const active = queueForm.workflowType === key;
return (
<button
key={key}
onClick={() => setQueueForm((f) => ({ ...f, workflowType: key }))}
style={{
flex: 1, padding: '0.3rem',
background: active ? `rgba(${rgb},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',
}}
>
{key}
</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, onDeleteMany, onClearCompleted }) {
const pendingCount = items.filter((i) => i.status === 'pending').length;
const completedCount = items.filter((i) => i.status === 'complete').length;
const [selectedIds, setSelectedIds] = useState(new Set());
// Drop any selected IDs that no longer exist in items
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const valid = new Set(items.map((i) => i.id));
const next = new Set([...prev].filter((id) => valid.has(id)));
return next.size === prev.size ? prev : next;
});
}, [items]);
const toggleSelect = (id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const handleDeleteSelected = () => {
onDeleteMany([...selectedIds]);
setSelectedIds(new Set());
};
// CARD items are their own top section; everything else groups by vendor
const grouped = useMemo(() => {
const cardItems = items.filter((i) => i.workflow_type === 'CARD');
const otherItems = items.filter((i) => i.workflow_type !== 'CARD');
const map = {};
otherItems.forEach((item) => {
const v = item.vendor || 'Unknown';
if (!map[v]) map[v] = [];
map[v].push(item);
});
const vendorGroups = Object.keys(map).sort().map((vendor) => ({
key: vendor, label: vendor, items: map[vendor], isCard: false,
}));
return cardItems.length > 0
? [{ key: '__CARD__', label: 'CARD', items: cardItems, isCard: true }, ...vendorGroups]
: vendorGroups;
}, [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(({ key, label, items: groupItems, isCard }) => (
<div key={key} style={{ marginBottom: '1.25rem' }}>
{/* Group header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.3rem 0', marginBottom: '0.375rem',
borderBottom: `1px solid ${isCard ? 'rgba(16,185,129,0.2)' : 'rgba(255,255,255,0.06)'}`,
}}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: isCard ? '#10B981' : '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
{label}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
{groupItems.length}
</span>
</div>
{/* Items */}
{groupItems.map((item) => {
const done = item.status === 'complete';
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
: item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' }
: { col: '#10B981', rgb: '16,185,129' };
const cves = item.cves || [];
const cveDisplay = cves.length > 0
? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '')
: '—';
const isCardItem = item.workflow_type === 'CARD';
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',
}}
>
{/* Selection checkbox — for bulk delete */}
<input
type="checkbox"
checked={selectedIds.has(item.id)}
onChange={() => toggleSelect(item.id)}
style={{ accentColor: '#EF4444', width: '12px', height: '12px', flexShrink: 0, marginTop: '3px', cursor: 'pointer', opacity: selectedIds.has(item.id) ? 1 : 0.35 }}
title="Select for deletion"
/>
{/* 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>
{isCardItem ? (
item.ip_address && (
<div style={{
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: done ? '#334155' : '#10B981',
textDecoration: done ? 'line-through' : 'none',
marginTop: '2px',
}}>
{item.ip_address}
</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: `rgba(${wfColor.rgb},0.12)`,
border: `1px solid rgba(${wfColor.rgb},0.3)`,
color: wfColor.col,
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,
display: 'flex', gap: '0.5rem',
}}>
{/* Delete selected — only shown when items are selected */}
{selectedIds.size > 0 && (
<button
onClick={handleDeleteSelected}
style={{
flex: 1, padding: '0.45rem',
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.35)',
borderRadius: '0.375rem',
color: '#EF4444',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: 'pointer',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
Delete ({selectedIds.size})
</button>
)}
<button
onClick={onClearCompleted}
disabled={completedCount === 0}
style={{
flex: 1, 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 +1633,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 +1710,116 @@ 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 || [],
ip_address: finding.ipAddress || null,
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 deleteQueueItems = useCallback(async (ids) => {
try {
await Promise.all(ids.map((id) =>
fetch(`${API_BASE}/ivanti/todo-queue/${id}`, { method: 'DELETE', credentials: 'include' })
));
const removed = new Set(ids);
setQueueItems((prev) => prev.filter((item) => !removed.has(item.id)));
} catch (e) {
console.error('Error bulk-deleting queue items:', 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 +2110,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 +2184,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 +2244,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 +2253,28 @@ 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}
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 +2283,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 +2305,32 @@ 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}
onDeleteMany={deleteQueueItems}
onClearCompleted={clearCompleted}
/>
</div> </div>
); );
} }