From cda1eaadc94fbf8e027392a99d950ef6e653c0a5 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Fri, 8 May 2026 14:51:05 -0600 Subject: [PATCH] Add DECOM workflow type, auto-note/hide on decom, show CVEs on CARD queue items, auto-run migrations in pipeline - Add DECOM to queue workflow types (red badge, inventory-style display) - When findings are added as DECOM, auto-set note to 'DECOM' and hide row - Hidden rows are excluded from donut charts (removes from pending count) - Show CVEs on CARD/GRANITE/DECOM queue items (was previously omitted) - Add backend/migrations/run-all.js for CI/CD auto-migration execution - Pipeline now runs migrations before service restart on both staging and prod - Add add_decom_workflow_type.js migration (updates CHECK constraint) --- .gitlab-ci.yml | 4 + backend/db-schema.sql | 2 +- backend/migrations/add_decom_workflow_type.js | 33 +++++ backend/migrations/run-all.js | 51 +++++++ backend/routes/ivantiTodoQueue.js | 139 +++++++++++++++--- .../src/components/pages/ReportingPage.js | 88 +++++++++-- 6 files changed, 287 insertions(+), 30 deletions(-) create mode 100644 backend/migrations/add_decom_workflow_type.js create mode 100644 backend/migrations/run-all.js diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e2dea98..b939313 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -173,6 +173,8 @@ deploy-staging: sed -i 's/^PORT=.*/PORT=3100/' ${STAGING_DIR}/backend/.env grep -q "^PORT=" ${STAGING_DIR}/backend/.env || echo "PORT=3100" >> ${STAGING_DIR}/backend/.env fi + # Run migrations + - cd ${STAGING_DIR}/backend && node migrations/run-all.js # Restart staging service - sudo systemctl restart cve-backend-staging || sudo systemctl start cve-backend-staging || true - echo "Staging deploy complete." @@ -213,6 +215,8 @@ deploy-production: # Install deps on production - ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline" - ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/frontend && npm ci --prefer-offline" + # Run migrations + - ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/backend && node migrations/run-all.js" # Restart services — install systemd unit if not present - ssh ${PROD_USER}@${PROD_HOST} "test -f /etc/systemd/system/cve-backend.service" || scp ${CI_PROJECT_DIR}/deploy/cve-backend-production.service ${PROD_USER}@${PROD_HOST}:/etc/systemd/system/cve-backend.service - ssh ${PROD_USER}@${PROD_HOST} "systemctl daemon-reload && systemctl enable cve-backend && systemctl restart cve-backend" diff --git a/backend/db-schema.sql b/backend/db-schema.sql index 9bcce87..bf1a631 100644 --- a/backend/db-schema.sql +++ b/backend/db-schema.sql @@ -319,7 +319,7 @@ CREATE TABLE IF NOT EXISTS ivanti_todo_queue ( ip_address TEXT, hostname TEXT, vendor TEXT NOT NULL, - workflow_type TEXT NOT NULL CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE')), + workflow_type TEXT NOT NULL CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM')), status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'complete')), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() diff --git a/backend/migrations/add_decom_workflow_type.js b/backend/migrations/add_decom_workflow_type.js new file mode 100644 index 0000000..65c5573 --- /dev/null +++ b/backend/migrations/add_decom_workflow_type.js @@ -0,0 +1,33 @@ +// Migration: Add DECOM to workflow_type CHECK constraint on ivanti_todo_queue +// Run from backend/: node migrations/add_decom_workflow_type.js + +const pool = require('../db'); + +async function migrate() { + console.log('Starting add_decom_workflow_type migration...'); + + try { + // Drop the existing constraint and add the updated one + await pool.query(` + ALTER TABLE ivanti_todo_queue + DROP CONSTRAINT IF EXISTS ivanti_todo_queue_workflow_type_check + `); + console.log('✓ Dropped old workflow_type constraint'); + + await pool.query(` + ALTER TABLE ivanti_todo_queue + ADD CONSTRAINT ivanti_todo_queue_workflow_type_check + CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM')) + `); + console.log('✓ Added updated workflow_type constraint (includes DECOM)'); + + console.log('Migration complete!'); + } catch (err) { + console.error('Migration failed:', err.message); + process.exit(1); + } finally { + await pool.end(); + } +} + +migrate(); diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js new file mode 100644 index 0000000..ff37755 --- /dev/null +++ b/backend/migrations/run-all.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Run all Postgres-compatible migrations in order. +// Each migration is idempotent (safe to re-run). +// Used by CI/CD pipeline during deploy to ensure schema is up to date. +// +// Usage: cd backend && node migrations/run-all.js + +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const MIGRATIONS_DIR = __dirname; + +// Only run migrations that use the Postgres pool (not legacy SQLite ones). +// Add new migrations to this list as they're created. +const POSTGRES_MIGRATIONS = [ + 'add_decom_workflow_type.js', +]; + +async function runAll() { + console.log(`[Migrations] Running ${POSTGRES_MIGRATIONS.length} Postgres migration(s)...`); + let succeeded = 0; + let failed = 0; + + for (const file of POSTGRES_MIGRATIONS) { + const fullPath = path.join(MIGRATIONS_DIR, file); + if (!fs.existsSync(fullPath)) { + console.error(` [FAIL] ${file}: file not found`); + failed++; + continue; + } + + try { + console.log(` [run] ${file}`); + execSync(`node ${fullPath}`, { + cwd: path.join(MIGRATIONS_DIR, '..'), + stdio: 'inherit', + timeout: 30000, + }); + succeeded++; + } catch (err) { + console.error(` [FAIL] ${file}: exit code ${err.status}`); + failed++; + } + } + + console.log(`[Migrations] Done: ${succeeded} applied, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +runAll(); diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js index 9531365..b48beee 100644 --- a/backend/routes/ivantiTodoQueue.js +++ b/backend/routes/ivantiTodoQueue.js @@ -4,7 +4,8 @@ const pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); -const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE']; +const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM']; +const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM']; const VALID_STATUSES = ['pending', 'complete']; function isValidVendor(vendor) { @@ -16,7 +17,26 @@ function isValidVendor(vendor) { function createIvantiTodoQueueRouter() { const router = express.Router(); - // GET /api/ivanti/todo-queue + /** + * GET /api/ivanti/todo-queue + * + * Returns all todo queue items belonging to the authenticated user. + * + * @query None + * @returns {Array} Array of queue items with parsed `cves` array + * - id {number} + * - user_id {number} + * - finding_id {string} + * - finding_title {string|null} + * - cves {Array} + * - ip_address {string|null} + * - hostname {string|null} + * - vendor {string} + * - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM + * - status {string} pending | complete + * - created_at {string} + * - updated_at {string} + */ router.get('/', requireAuth(), async (req, res) => { try { const { rows } = await pool.query( @@ -37,7 +57,25 @@ function createIvantiTodoQueueRouter() { } }); - // POST /api/ivanti/todo-queue/batch + /** + * POST /api/ivanti/todo-queue/batch + * + * Adds multiple findings to the authenticated user's todo queue in a single transaction. + * Requires Admin or Standard_User group. + * + * @body {Object} + * - findings {Array} 1–200 items, each with: + * - finding_id {string} Required, non-empty + * - finding_title {string} Optional, max 500 chars + * - cves {Array} Optional + * - ip_address {string} Optional, max 64 chars + * - hostname {string} Optional, max 255 chars + * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM + * - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars + * @returns {Object} { items: Array } — inserted queue items with parsed `cves` array + * @error 400 Invalid input + * @error 500 Internal server error + */ router.post('/batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { findings, workflow_type, vendor } = req.body; @@ -53,10 +91,10 @@ function createIvantiTodoQueueRouter() { } if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { - return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' }); + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' }); } - if (!['CARD', 'GRANITE'].includes(workflow_type)) { + if (!INVENTORY_TYPES.includes(workflow_type)) { if (!isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); } @@ -66,7 +104,7 @@ function createIvantiTodoQueueRouter() { return res.status(400).json({ error: 'vendor must be under 200 chars.' }); } - const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim(); + const vendorVal = INVENTORY_TYPES.includes(workflow_type) ? '' : vendor.trim(); const userId = req.user.id; const client = await pool.connect(); @@ -131,7 +169,24 @@ function createIvantiTodoQueueRouter() { } }); - // POST /api/ivanti/todo-queue + /** + * POST /api/ivanti/todo-queue + * + * Adds a single finding to the authenticated user's todo queue. + * Requires Admin or Standard_User group. + * + * @body {Object} + * - finding_id {string} Required, non-empty + * - finding_title {string} Optional, max 500 chars + * - cves {Array} Optional + * - ip_address {string} Optional, max 64 chars + * - hostname {string} Optional, max 255 chars + * - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars + * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM + * @returns {Object} The created queue item with parsed `cves` array + * @error 400 Invalid input + * @error 500 Internal server error + */ router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { finding_id, finding_title, cves, ip_address, hostname, vendor, workflow_type } = req.body; @@ -139,16 +194,16 @@ function createIvantiTodoQueueRouter() { 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, CARD, or GRANITE.' }); + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' }); } - if (!['CARD', 'GRANITE'].includes(workflow_type) && !isValidVendor(vendor)) { + if (!INVENTORY_TYPES.includes(workflow_type) && !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 = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim(); + const vendorVal = INVENTORY_TYPES.includes(workflow_type) ? '' : 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 hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null; @@ -175,7 +230,22 @@ function createIvantiTodoQueueRouter() { } }); - // PUT /api/ivanti/todo-queue/:id + /** + * PUT /api/ivanti/todo-queue/:id + * + * Updates an existing queue item owned by the authenticated user. + * Requires Admin or Standard_User group. + * + * @param {string} id — Queue item ID (URL parameter) + * @body {Object} At least one field required: + * - vendor {string} Optional, non-empty, max 200 chars + * - workflow_type {string} Optional. One of: FP, Archer, CARD, GRANITE, DECOM + * - status {string} Optional. One of: pending, complete + * @returns {Object} The updated queue item with parsed `cves` array + * @error 400 Invalid input or no fields to update + * @error 404 Queue item not found + * @error 500 Internal server error + */ router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { vendor, workflow_type, status } = req.body; @@ -184,7 +254,7 @@ function createIvantiTodoQueueRouter() { 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, Archer, CARD, or GRANITE.' }); + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' }); } if (status !== undefined && !VALID_STATUSES.includes(status)) { return res.status(400).json({ error: 'status must be pending or complete.' }); @@ -242,15 +312,30 @@ function createIvantiTodoQueueRouter() { } }); - // POST /api/ivanti/todo-queue/:id/redirect + /** + * POST /api/ivanti/todo-queue/:id/redirect + * + * Redirects a completed queue item to a different workflow by creating a new + * pending queue item with the same finding data but a new workflow type/vendor. + * Requires Admin or Standard_User group. + * + * @param {string} id — Queue item ID of the completed item (URL parameter) + * @body {Object} + * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM + * - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars + * @returns {Object} The newly created queue item with parsed `cves` array + * @error 400 Invalid input or item not in complete status + * @error 404 Queue item not found + * @error 500 Internal server error + */ router.post('/:id/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { workflow_type, vendor } = req.body; if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { - return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' }); + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' }); } - if (!['CARD', 'GRANITE'].includes(workflow_type)) { + if (!INVENTORY_TYPES.includes(workflow_type)) { if (!isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); } @@ -259,7 +344,7 @@ function createIvantiTodoQueueRouter() { return res.status(400).json({ error: 'vendor must be under 200 chars.' }); } - const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim(); + const vendorVal = INVENTORY_TYPES.includes(workflow_type) ? '' : vendor.trim(); try { const { rows: origRows } = await pool.query( @@ -308,7 +393,15 @@ function createIvantiTodoQueueRouter() { } }); - // DELETE /api/ivanti/todo-queue/completed + /** + * DELETE /api/ivanti/todo-queue/completed + * + * Deletes all completed queue items belonging to the authenticated user. + * Requires Admin or Standard_User group. + * + * @returns {Object} { message: string, deleted: number } + * @error 500 Internal server error + */ router.delete('/completed', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { try { const result = await pool.query( @@ -322,7 +415,17 @@ function createIvantiTodoQueueRouter() { } }); - // DELETE /api/ivanti/todo-queue/:id + /** + * DELETE /api/ivanti/todo-queue/:id + * + * Deletes a single queue item owned by the authenticated user. + * Requires Admin or Standard_User group. + * + * @param {string} id — Queue item ID (URL parameter) + * @returns {Object} { message: string } + * @error 404 Queue item not found + * @error 500 Internal server error + */ router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index ce73f04..a23a2ac 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1387,7 +1387,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd return () => document.removeEventListener('keydown', handler); }, [onCancel]); - const isCard = queueForm.workflowType === 'CARD' || queueForm.workflowType === 'GRANITE'; + const isCard = queueForm.workflowType === 'CARD' || queueForm.workflowType === 'GRANITE' || queueForm.workflowType === 'DECOM'; const canSubmit = isCard || queueForm.vendor.trim().length > 0; return ReactDOM.createPortal( @@ -1457,6 +1457,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd { key: 'Archer', col: '#0EA5E9', rgb: '14,165,233' }, { key: 'CARD', col: '#10B981', rgb: '16,185,129' }, { key: 'GRANITE', col: '#A1887F', rgb: '161,136,127' }, + { key: 'DECOM', col: '#EF4444', rgb: '239,68,68' }, ].map(({ key, col, rgb }) => { const active = queueForm.workflowType === key; return ( @@ -1683,12 +1684,13 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' } : item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' } : item.workflow_type === 'GRANITE' ? { col: '#A1887F', rgb: '161,136,127' } + : item.workflow_type === 'DECOM' ? { col: '#EF4444', rgb: '239,68,68' } : { 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 isInventoryItem = item.workflow_type === 'CARD' || item.workflow_type === 'GRANITE'; + const isInventoryItem = item.workflow_type === 'CARD' || item.workflow_type === 'GRANITE' || item.workflow_type === 'DECOM'; return (
)} + {cves.length > 0 && ( +
+ {cveDisplay} +
+ )} ) : ( <> @@ -2112,12 +2125,13 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on return null; }; - // Inventory items (CARD + GRANITE) are their own top section; everything else groups by vendor + // Inventory items (CARD + GRANITE + DECOM) are their own top section; everything else groups by vendor const grouped = useMemo(() => { - const inventoryItems = items.filter((i) => i.workflow_type === 'CARD' || i.workflow_type === 'GRANITE'); + const inventoryItems = items.filter((i) => i.workflow_type === 'CARD' || i.workflow_type === 'GRANITE' || i.workflow_type === 'DECOM'); const cardItems = inventoryItems.filter((i) => i.workflow_type === 'CARD'); const graniteItems = inventoryItems.filter((i) => i.workflow_type === 'GRANITE'); - const otherItems = items.filter((i) => i.workflow_type !== 'CARD' && i.workflow_type !== 'GRANITE'); + const decomItems = inventoryItems.filter((i) => i.workflow_type === 'DECOM'); + const otherItems = items.filter((i) => i.workflow_type !== 'CARD' && i.workflow_type !== 'GRANITE' && i.workflow_type !== 'DECOM'); const map = {}; otherItems.forEach((item) => { @@ -2130,7 +2144,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on })); return inventoryItems.length > 0 - ? [{ key: '__INVENTORY__', label: 'Inventory', cardItems, graniteItems, items: inventoryItems, isInventory: true }, ...vendorGroups] + ? [{ key: '__INVENTORY__', label: 'Inventory', cardItems, graniteItems, decomItems, items: inventoryItems, isInventory: true }, ...vendorGroups] : vendorGroups; }, [items]); @@ -2204,7 +2218,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on Check a row in the findings table to add it.
- ) : grouped.map(({ key, label, items: groupItems, isInventory, cardItems, graniteItems }) => ( + ) : grouped.map(({ key, label, items: groupItems, isInventory, cardItems, graniteItems, decomItems }) => (
{/* Group header */}
- {/* Items — Inventory section renders CARD then GRANITE with optional sub-divider */} + {/* Items — Inventory section renders CARD then GRANITE then DECOM with optional sub-dividers */} {isInventory ? ( <> {cardItems.map((item) => ( @@ -2237,6 +2251,14 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on }} /> )} {graniteItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))} + {(cardItems.length > 0 || graniteItems.length > 0) && decomItems.length > 0 && ( +
+ )} + {decomItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))} ) : ( groupItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite })) @@ -4086,7 +4108,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) { // SelectionToolbar — batch action bar for multi-selected findings // --------------------------------------------------------------------------- function SelectionToolbar({ count, workflowType, vendor, submitting, error, onWorkflowChange, onVendorChange, onSubmit, onClear }) { - const isCard = workflowType === 'CARD' || workflowType === 'GRANITE'; + const isCard = workflowType === 'CARD' || workflowType === 'GRANITE' || workflowType === 'DECOM'; const canSubmit = !submitting && (isCard || vendor.trim().length > 0); return ( @@ -4122,6 +4144,7 @@ function SelectionToolbar({ count, workflowType, vendor, submitting, error, onWo { type: 'Archer', color: '#0EA5E9', rgb: '14,165,233' }, { type: 'CARD', color: '#10B981', rgb: '16,185,129' }, { type: 'GRANITE', color: '#A1887F', rgb: '161,136,127' }, + { type: 'DECOM', color: '#EF4444', rgb: '239,68,68' }, ].map(({ type, color, rgb }) => { const active = workflowType === type; return ( @@ -5426,13 +5449,30 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { setQueueItems((prev) => [...prev, data].sort((a, b) => a.vendor.localeCompare(b.vendor) || a.id - b.id )); + + // DECOM: auto-set note and auto-hide the finding + if (queueForm.workflowType === 'DECOM') { + // Set note to DECOM + fetch(`${API_BASE}/ivanti/findings/${finding.id}/note`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ note: 'DECOM' }), + }).catch(() => {}); + // Update local findings state + setFindings(prev => prev.map(f => + f.id === finding.id ? { ...f, note: 'DECOM' } : f + )); + // Auto-hide the row + hideRow(finding.id); + } } } catch (e) { console.error('Error adding to queue:', e); } setAddPopover(null); setQueueForm({ vendor: '', workflowType: 'FP' }); - }, [addPopover, queueForm]); + }, [addPopover, queueForm, hideRow]); // Prune selection when filters change — keep only IDs still in filtered set useEffect(() => { @@ -5479,7 +5519,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { body: JSON.stringify({ findings: findingsPayload, workflow_type: batchWorkflowType, - vendor: batchWorkflowType === 'CARD' ? '' : batchVendor.trim(), + vendor: batchWorkflowType === 'CARD' || batchWorkflowType === 'GRANITE' || batchWorkflowType === 'DECOM' ? '' : batchVendor.trim(), }), }); const data = await res.json(); @@ -5487,6 +5527,32 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { setQueueItems((prev) => [...prev, ...(data.items || [])].sort((a, b) => (a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id )); + + // DECOM: auto-set note and auto-hide all selected findings + if (batchWorkflowType === 'DECOM') { + const ids = [...selectedIds]; + // Set notes to DECOM in parallel (fire-and-forget) + ids.forEach(id => { + fetch(`${API_BASE}/ivanti/findings/${id}/note`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ note: 'DECOM' }), + }).catch(() => {}); + }); + // Update local findings state + setFindings(prev => prev.map(f => + ids.includes(f.id) ? { ...f, note: 'DECOM' } : f + )); + // Auto-hide all + setHiddenRowIds(prev => { + const next = new Set(prev); + ids.forEach(id => next.add(String(id))); + saveHiddenRows(next); + return next; + }); + } + setSelectedIds(new Set()); setBatchWorkflowType('FP'); setBatchVendor('');