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>
This commit is contained in:
78
backend/migrations/add_card_workflow_type.js
Normal file
78
backend/migrations/add_card_workflow_type.js
Normal 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!');
|
||||
});
|
||||
43
backend/migrations/add_ivanti_todo_queue_table.js
Normal file
43
backend/migrations/add_ivanti_todo_queue_table.js
Normal 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!');
|
||||
});
|
||||
25
backend/migrations/add_todo_queue_ip_address.js
Normal file
25
backend/migrations/add_todo_queue_ip_address.js
Normal 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!'));
|
||||
}
|
||||
);
|
||||
214
backend/routes/ivantiTodoQueue.js
Normal file
214
backend/routes/ivantiTodoQueue.js
Normal 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;
|
||||
@@ -22,6 +22,7 @@ const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -123,8 +124,35 @@ app.use('/uploads', express.static('uploads', {
|
||||
|
||||
// Database connection
|
||||
const db = new sqlite3.Database('./cve_database.db', (err) => {
|
||||
if (err) console.error('Database connection error:', err);
|
||||
else console.log('Connected to CVE database');
|
||||
if (err) {
|
||||
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)
|
||||
@@ -187,6 +215,9 @@ app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
|
||||
// Ivanti / RiskSense host findings routes (all authenticated users)
|
||||
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 ==========
|
||||
|
||||
// Get all CVEs with optional filters (authenticated users)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
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 { 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1174,6 +1633,7 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
||||
fetchFindings();
|
||||
fetchCounts();
|
||||
fetchFPWorkflowCounts();
|
||||
fetchQueue();
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 exportBtnRef = useRef(null);
|
||||
|
||||
@@ -1540,6 +2110,35 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
||||
</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} />
|
||||
<button
|
||||
onClick={syncFindings}
|
||||
@@ -1585,6 +2184,15 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
|
||||
<thead>
|
||||
<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) => {
|
||||
const def = COLUMN_DEFS[col.key];
|
||||
const active = sort.field === col.key;
|
||||
@@ -1636,7 +2244,8 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr
|
||||
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)'}
|
||||
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) => (
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} />
|
||||
))}
|
||||
@@ -1652,7 +2283,7 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
||||
})}
|
||||
{sorted.length === 0 && (
|
||||
<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'}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1674,6 +2305,32 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user