2026-03-11 13:03:17 -06:00
import React , { useState , useEffect , useCallback , useRef , useMemo } from 'react' ;
import ReactDOM from 'react-dom' ;
2026-04-15 15:27:21 -06:00
import { RefreshCw , Loader , AlertCircle , PieChart , ChevronUp , ChevronDown , ChevronsUpDown , Settings2 , GripVertical , Eye , EyeOff , Filter , Download , RotateCcw , Trash2 , X , ListTodo , Upload , FileText , Check , AlertTriangle , CornerUpRight , Edit3 , Square , CheckSquare , MinusSquare , Search , Database } from 'lucide-react' ;
2026-03-13 12:08:20 -06:00
import * as XLSX from 'xlsx' ;
2026-03-13 15:39:37 -06:00
import { useAuth } from '../../contexts/AuthContext' ;
2026-04-02 10:12:04 -06:00
import IvantiCountsChart from './IvantiCountsChart' ;
2026-04-09 14:42:23 -06:00
import CveTooltip from '../CveTooltip' ;
2026-04-09 16:01:36 -06:00
import RedirectModal from '../RedirectModal' ;
2026-03-11 11:47:03 -06:00
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
const API _BASE = process . env . REACT _APP _API _BASE || 'http://localhost:3001/api' ;
2026-03-11 14:44:53 -06:00
const STORAGE _KEY = 'steam_findings_columns_v2' ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
2026-03-16 13:27:16 -06:00
// Sentinel used in filter Sets to represent cells with no value (blank / —)
const EMPTY _SENTINEL = '__EMPTY__' ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
// ---------------------------------------------------------------------------
2026-03-11 12:47:11 -06:00
// Column definitions — source of truth for labels, sort behaviour, rendering
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
// ---------------------------------------------------------------------------
2026-03-11 12:47:11 -06:00
const COLUMN _DEFS = {
2026-03-11 14:23:50 -06:00
findingId : { label : 'Finding ID' , sortable : true , filterable : false } ,
2026-03-11 13:17:01 -06:00
severity : { label : 'Severity' , sortable : true , filterable : true } ,
title : { label : 'Title' , sortable : true , filterable : true } ,
cves : { label : 'CVEs' , sortable : true , filterable : true , multiValue : true } ,
hostName : { label : 'Host' , sortable : true , filterable : true } ,
ipAddress : { label : 'IP Address' , sortable : true , filterable : true } ,
dns : { label : 'DNS' , sortable : true , filterable : true } ,
dueDate : { label : 'Due Date' , sortable : true , filterable : true } ,
2026-03-11 14:44:53 -06:00
slaStatus : { label : 'SLA' , sortable : true , filterable : true } ,
2026-03-11 13:17:01 -06:00
buOwnership : { label : 'BU' , sortable : true , filterable : true } ,
2026-03-11 14:44:53 -06:00
workflow : { label : 'Workflow' , sortable : true , filterable : true } ,
2026-03-11 13:17:01 -06:00
lastFoundOn : { label : 'Last Found' , sortable : true , filterable : true } ,
note : { label : 'Notes' , sortable : false , filterable : false } ,
2026-03-11 12:47:11 -06:00
} ;
const DEFAULT _COLUMN _ORDER = [
2026-03-11 14:23:50 -06:00
{ key : 'findingId' , visible : true } ,
2026-03-11 12:47:11 -06:00
{ key : 'severity' , visible : true } ,
{ key : 'title' , visible : true } ,
2026-03-11 13:17:01 -06:00
{ key : 'cves' , visible : true } ,
2026-03-11 12:47:11 -06:00
{ key : 'hostName' , visible : true } ,
{ key : 'ipAddress' , visible : true } ,
{ key : 'dns' , visible : true } ,
{ key : 'dueDate' , visible : true } ,
{ key : 'slaStatus' , visible : true } ,
2026-03-11 13:03:17 -06:00
{ key : 'buOwnership' , visible : true } ,
2026-03-11 14:44:53 -06:00
{ key : 'workflow' , visible : true } ,
2026-03-11 12:47:11 -06:00
{ key : 'lastFoundOn' , visible : true } ,
{ key : 'note' , visible : true } ,
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
] ;
// ---------------------------------------------------------------------------
2026-03-11 12:47:11 -06:00
// Persist / load column config
// ---------------------------------------------------------------------------
function loadColumnOrder ( ) {
try {
const saved = JSON . parse ( localStorage . getItem ( STORAGE _KEY ) || 'null' ) ;
if ( saved && Array . isArray ( saved ) ) {
const savedKeys = new Set ( saved . map ( ( c ) => c . key ) ) ;
2026-03-11 13:03:17 -06:00
const merged = saved . filter ( ( c ) => COLUMN _DEFS [ c . key ] ) ;
2026-03-11 12:47:11 -06:00
DEFAULT _COLUMN _ORDER . forEach ( ( d ) => {
if ( ! savedKeys . has ( d . key ) ) merged . push ( { ... d } ) ;
} ) ;
return merged ;
}
} catch { /* ignore */ }
return DEFAULT _COLUMN _ORDER . map ( ( c ) => ( { ... c } ) ) ;
}
function saveColumnOrder ( order ) {
try { localStorage . setItem ( STORAGE _KEY , JSON . stringify ( order ) ) ; } catch { /* ignore */ }
}
2026-04-15 13:15:01 -06:00
// ---------------------------------------------------------------------------
// Persist / load hidden row IDs (row visibility feature)
// ---------------------------------------------------------------------------
const HIDDEN _ROWS _KEY = 'steam_findings_hidden_rows' ;
function loadHiddenRows ( ) {
try {
const saved = JSON . parse ( localStorage . getItem ( HIDDEN _ROWS _KEY ) || 'null' ) ;
if ( saved && Array . isArray ( saved ) ) return new Set ( saved ) ;
} catch { /* corrupted — treat as empty */ }
return new Set ( ) ;
}
function saveHiddenRows ( hiddenSet ) {
try { localStorage . setItem ( HIDDEN _ROWS _KEY , JSON . stringify ( [ ... hiddenSet ] ) ) ; } catch { /* ignore */ }
}
2026-03-11 12:47:11 -06:00
// ---------------------------------------------------------------------------
// Sort accessor by column key
// ---------------------------------------------------------------------------
function getVal ( finding , key ) {
switch ( key ) {
2026-03-11 14:23:50 -06:00
case 'findingId' : return finding . id ? ? '' ;
2026-03-11 12:47:11 -06:00
case 'severity' : return finding . severity ? ? 0 ;
case 'title' : return finding . title ? ? '' ;
case 'hostName' : return finding . hostName ? ? '' ;
case 'ipAddress' : return finding . ipAddress ? ? '' ;
case 'dns' : return finding . dns ? ? '' ;
case 'dueDate' : return finding . dueDate ? ? '' ;
case 'slaStatus' : return finding . slaStatus ? ? '' ;
2026-03-11 13:17:01 -06:00
case 'cves' : return ( finding . cves || [ ] ) . length ; // sort by CVE count
2026-03-11 13:03:17 -06:00
case 'buOwnership' : return finding . buOwnership ? ? '' ;
2026-03-11 14:44:53 -06:00
case 'workflow' : return finding . workflow ? . id ? ? '' ;
2026-03-11 12:47:11 -06:00
case 'lastFoundOn' : return finding . lastFoundOn ? ? '' ;
case 'note' : return finding . note ? ? '' ;
default : return '' ;
}
}
2026-03-11 13:03:17 -06:00
// ---------------------------------------------------------------------------
2026-03-11 13:17:01 -06:00
// Filter accessor — severity → vrrGroup label; cves handled as multi-value
2026-03-11 13:03:17 -06:00
// ---------------------------------------------------------------------------
function getFilterVal ( finding , key ) {
if ( key === 'severity' ) return finding . vrrGroup || '' ;
2026-03-11 13:17:01 -06:00
if ( key === 'cves' ) return ( finding . cves || [ ] ) . join ( ',' ) ; // not used directly; see multiValue logic
2026-03-11 14:44:53 -06:00
if ( key === 'workflow' ) return finding . workflow ? . id || '' ;
2026-03-11 13:03:17 -06:00
return String ( getVal ( finding , key ) ? ? '' ) ;
}
2026-03-13 12:08:20 -06:00
// ---------------------------------------------------------------------------
// Export value accessor — plain text representation for CSV/XLSX
// ---------------------------------------------------------------------------
function getExportVal ( finding , key ) {
switch ( key ) {
case 'findingId' : return finding . id ? ? '' ;
case 'severity' : return finding . vrrGroup ? ` ${ finding . severity ? . toFixed ( 2 ) } ${ finding . vrrGroup } ` : String ( finding . severity ? ? '' ) ;
case 'title' : return finding . title ? ? '' ;
case 'cves' : return ( finding . cves || [ ] ) . join ( ', ' ) ;
case 'hostName' : return finding . hostName ? ? '' ;
case 'ipAddress' : return finding . ipAddress ? ? '' ;
case 'dns' : return finding . dns ? ? '' ;
case 'dueDate' : return finding . dueDate ? ? '' ;
case 'slaStatus' : return finding . slaStatus ? ? '' ;
case 'buOwnership' : return finding . buOwnership ? ? '' ;
case 'workflow' : return finding . workflow ? ` ${ finding . workflow . id } ( ${ finding . workflow . state } ) ` : '' ;
case 'lastFoundOn' : return finding . lastFoundOn ? ? '' ;
case 'note' : return finding . note ? ? '' ;
default : return '' ;
}
}
2026-03-13 13:06:54 -06:00
// ---------------------------------------------------------------------------
// Action coverage classification — used by chart and filter
// ---------------------------------------------------------------------------
const EXC _PATTERN = /EXC-\d+/i ;
function classifyFinding ( finding ) {
if ( finding . workflow != null ) return 'fp' ;
if ( EXC _PATTERN . test ( finding . note || '' ) ) return 'archer' ;
return 'pending' ;
}
2026-03-11 12:47:11 -06:00
// ---------------------------------------------------------------------------
// Style helpers
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
// ---------------------------------------------------------------------------
function severityColor ( vrrGroup ) {
switch ( ( vrrGroup || '' ) . toUpperCase ( ) ) {
2026-03-11 12:47:11 -06:00
case 'CRITICAL' : return { bg : 'rgba(239,68,68,0.15)' , border : '#EF4444' , text : '#EF4444' } ;
case 'HIGH' : return { bg : 'rgba(245,158,11,0.15)' , border : '#F59E0B' , text : '#F59E0B' } ;
case 'MEDIUM' : return { bg : 'rgba(234,179,8,0.15)' , border : '#EAB308' , text : '#EAB308' } ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
default : return { bg : 'rgba(100,116,139,0.15)' , border : '#64748B' , text : '#94A3B8' } ;
}
}
function slaColor ( slaStatus ) {
switch ( ( slaStatus || '' ) . toUpperCase ( ) ) {
2026-03-11 12:47:11 -06:00
case 'OVERDUE' : return '#EF4444' ;
case 'AT_RISK' : return '#F59E0B' ;
case 'WITHIN_SLA' : return '#10B981' ;
default : return '#64748B' ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
}
}
2026-03-11 12:47:11 -06:00
function dueDateColor ( dueDate ) {
if ( ! dueDate ) return '#64748B' ;
const today = new Date ( ) ;
const due = new Date ( dueDate ) ;
const diffDays = Math . ceil ( ( due - today ) / ( 1000 * 60 * 60 * 24 ) ) ;
2026-03-11 13:03:17 -06:00
if ( diffDays < 0 ) return '#EF4444' ;
if ( diffDays <= 30 ) return '#F59E0B' ;
2026-03-11 12:47:11 -06:00
return '#94A3B8' ;
}
2026-03-11 14:44:53 -06:00
function workflowStyle ( state ) {
2026-03-11 15:36:02 -06:00
// Colors reflect action urgency — all findings here are Open, so Approved won't appear.
2026-03-11 14:44:53 -06:00
switch ( ( state || '' ) . toLowerCase ( ) ) {
2026-03-11 15:36:02 -06:00
case 'expired' : return { bg : 'rgba(239,68,68,0.12)' , border : 'rgba(239,68,68,0.4)' , text : '#EF4444' } ; // overdue — renew FP
case 'rejected' : return { bg : 'rgba(239,68,68,0.12)' , border : 'rgba(239,68,68,0.4)' , text : '#EF4444' } ; // denied — must remediate
case 'reworked' : return { bg : 'rgba(245,158,11,0.12)' , border : 'rgba(245,158,11,0.4)' , text : '#F59E0B' } ; // challenged — resubmit FP
case 'actionable' : return { bg : 'rgba(245,158,11,0.12)' , border : 'rgba(245,158,11,0.4)' , text : '#F59E0B' } ; // needs action
case 'requested' : return { bg : 'rgba(14,165,233,0.12)' , border : 'rgba(14,165,233,0.35)' , text : '#0EA5E9' } ; // in flight — awaiting approval
default : return { bg : 'rgba(100,116,139,0.08)' , border : 'rgba(100,116,139,0.2)' , text : '#64748B' } ; // unknown state
2026-03-11 14:44:53 -06:00
}
}
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
function lifecycleStatusBadge ( status ) {
switch ( ( status || '' ) . toLowerCase ( ) ) {
case 'submitted' :
case 'resubmitted' :
return { bg : 'rgba(14,165,233,0.12)' , border : 'rgba(14,165,233,0.4)' , text : '#0EA5E9' } ;
case 'approved' :
return { bg : 'rgba(16,185,129,0.12)' , border : 'rgba(16,185,129,0.4)' , text : '#10B981' } ;
case 'rejected' :
return { bg : 'rgba(239,68,68,0.12)' , border : 'rgba(239,68,68,0.4)' , text : '#EF4444' } ;
case 'rework' :
return { bg : 'rgba(245,158,11,0.12)' , border : 'rgba(245,158,11,0.4)' , text : '#F59E0B' } ;
default :
return { bg : 'rgba(100,116,139,0.08)' , border : 'rgba(100,116,139,0.2)' , text : '#64748B' } ;
}
}
2026-03-13 12:23:05 -06:00
// ---------------------------------------------------------------------------
// SVG Donut Chart — Open vs Closed findings
// ---------------------------------------------------------------------------
function polarToCartesian ( cx , cy , r , angleDeg ) {
const rad = ( ( angleDeg - 90 ) * Math . PI ) / 180 ;
return { x : cx + r * Math . cos ( rad ) , y : cy + r * Math . sin ( rad ) } ;
}
function donutArcPath ( cx , cy , outerR , innerR , startDeg , endDeg ) {
// Full circle must be split into two arcs (SVG can't render a 360° arc)
if ( Math . abs ( endDeg - startDeg ) >= 359.9 ) {
const mid = startDeg + 180 ;
return donutArcPath ( cx , cy , outerR , innerR , startDeg , mid ) + ' ' +
donutArcPath ( cx , cy , outerR , innerR , mid , endDeg ) ;
}
const largeArc = endDeg - startDeg > 180 ? 1 : 0 ;
const s = polarToCartesian ( cx , cy , outerR , startDeg ) ;
const e = polarToCartesian ( cx , cy , outerR , endDeg ) ;
const si = polarToCartesian ( cx , cy , innerR , endDeg ) ;
const ei = polarToCartesian ( cx , cy , innerR , startDeg ) ;
return [
` M ${ s . x . toFixed ( 2 ) } ${ s . y . toFixed ( 2 ) } ` ,
` A ${ outerR } ${ outerR } 0 ${ largeArc } 1 ${ e . x . toFixed ( 2 ) } ${ e . y . toFixed ( 2 ) } ` ,
` L ${ si . x . toFixed ( 2 ) } ${ si . y . toFixed ( 2 ) } ` ,
` A ${ innerR } ${ innerR } 0 ${ largeArc } 0 ${ ei . x . toFixed ( 2 ) } ${ ei . y . toFixed ( 2 ) } ` ,
'Z' ,
] . join ( ' ' ) ;
}
function StatusDonut ( { open , closed , loading } ) {
const SIZE = 180 ;
const CX = SIZE / 2 ;
const CY = SIZE / 2 ;
const OUTER = 72 ;
const INNER = 48 ;
if ( loading ) {
return (
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'center' , height : ` ${ SIZE } px ` } } >
< Loader style = { { width : '24px' , height : '24px' , color : '#0EA5E9' , animation : 'spin 1s linear infinite' } } / >
< / d i v >
) ;
}
const total = open + closed ;
if ( total === 0 ) {
return (
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'center' , height : ` ${ SIZE } px ` } } >
< p style = { { fontFamily : 'monospace' , fontSize : '0.75rem' , color : '#475569' } } > No data — click Sync to load < / p >
< / d i v >
) ;
}
const openDeg = ( open / total ) * 360 ;
const segments = [
{ label : 'Open' , count : open , color : '#0EA5E9' , start : 0 , end : openDeg } ,
{ label : 'Closed' , count : closed , color : '#475569' , start : openDeg , end : 360 } ,
] . filter ( ( s ) => s . count > 0 ) ;
return (
< div style = { { display : 'flex' , alignItems : 'center' , gap : '2rem' } } >
< svg width = { SIZE } height = { SIZE } style = { { flexShrink : 0 } } >
{ /* Gap ring behind slices */ }
< circle cx = { CX } cy = { CY } r = { OUTER + 1 } fill = "none" stroke = "rgba(10,18,32,0.8)" strokeWidth = "2" / >
{ segments . map ( ( seg ) => (
< path
key = { seg . label }
d = { donutArcPath ( CX , CY , OUTER , INNER , seg . start , seg . end ) }
fill = { seg . color }
opacity = { 0.88 }
/ >
) ) }
{ /* Center total */ }
< text x = { CX } y = { CY - 10 } textAnchor = "middle" style = { { fontFamily : 'monospace' , fontSize : '22px' , fontWeight : '700' , fill : '#E2E8F0' } } >
{ total . toLocaleString ( ) }
< / t e x t >
< text x = { CX } y = { CY + 10 } textAnchor = "middle" style = { { fontFamily : 'monospace' , fontSize : '8.5px' , fontWeight : '600' , fill : '#475569' , letterSpacing : '0.12em' } } >
TOTAL
< / t e x t >
< / s v g >
{ /* Legend */ }
< div style = { { display : 'flex' , flexDirection : 'column' , gap : '1rem' } } >
{ segments . map ( ( seg ) => (
< div key = { seg . label } style = { { display : 'flex' , alignItems : 'center' , gap : '0.625rem' } } >
< div style = { { width : '10px' , height : '10px' , borderRadius : '2px' , background : seg . color , flexShrink : 0 } } / >
< div >
< div style = { { fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' , color : '#64748B' , textTransform : 'uppercase' , letterSpacing : '0.08em' } } >
{ seg . label }
< / d i v >
< div style = { { fontFamily : 'monospace' , fontSize : '0.9rem' , fontWeight : '700' , color : '#E2E8F0' , lineHeight : 1.2 } } >
{ seg . count . toLocaleString ( ) }
< span style = { { fontSize : '0.68rem' , fontWeight : '400' , color : '#64748B' , marginLeft : '0.4rem' } } >
( { ( ( seg . count / total ) * 100 ) . toFixed ( 1 ) } % )
< / s p a n >
< / d i v >
< / d i v >
< / d i v >
) ) }
< / d i v >
< / d i v >
) ;
}
2026-03-13 12:50:15 -06:00
// ---------------------------------------------------------------------------
2026-03-13 13:06:54 -06:00
// SVG Donut Chart — Action Coverage (FP Request | Archer Exception | Pending)
2026-03-13 12:50:15 -06:00
// ---------------------------------------------------------------------------
2026-03-13 13:06:54 -06:00
const ACTION _DEFS = [
{ key : 'fp' , label : 'FP Request' , color : '#0EA5E9' } ,
{ key : 'archer' , label : 'Archer Exception' , color : '#F59E0B' } ,
{ key : 'pending' , label : 'Pending' , color : '#EF4444' } ,
2026-03-13 12:50:15 -06:00
] ;
2026-03-13 13:06:54 -06:00
function ActionCoverageDonut ( { findings , activeSegment , onSegmentClick } ) {
2026-03-13 12:50:15 -06:00
const SIZE = 180 ;
const CX = SIZE / 2 ;
const CY = SIZE / 2 ;
const OUTER = 72 ;
const INNER = 48 ;
const counts = useMemo ( ( ) => {
2026-03-13 13:06:54 -06:00
const map = { fp : 0 , archer : 0 , pending : 0 } ;
findings . forEach ( ( f ) => { map [ classifyFinding ( f ) ] ++ ; } ) ;
2026-03-13 12:50:15 -06:00
return map ;
} , [ findings ] ) ;
2026-03-13 13:06:54 -06:00
const total = findings . length ;
2026-03-13 12:50:15 -06:00
if ( total === 0 ) {
return (
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'center' , height : ` ${ SIZE } px ` } } >
< p style = { { fontFamily : 'monospace' , fontSize : '0.75rem' , color : '#475569' } } > No data — click Sync to load < / p >
< / d i v >
) ;
}
let cursor = 0 ;
2026-03-13 13:06:54 -06:00
const segments = ACTION _DEFS . map ( ( def ) => {
const count = counts [ def . key ] ;
const start = cursor ;
const end = count > 0 ? cursor + ( count / total ) * 360 : cursor ;
if ( count > 0 ) cursor = end ;
return { ... def , count , start , end } ;
} ) ;
const hasActive = ! ! activeSegment ;
2026-03-13 12:50:15 -06:00
return (
< div style = { { display : 'flex' , alignItems : 'center' , gap : '2rem' } } >
< svg width = { SIZE } height = { SIZE } style = { { flexShrink : 0 } } >
< circle cx = { CX } cy = { CY } r = { OUTER + 1 } fill = "none" stroke = "rgba(10,18,32,0.8)" strokeWidth = "2" / >
2026-03-13 13:06:54 -06:00
{ segments . filter ( ( s ) => s . count > 0 ) . map ( ( seg ) => {
const isActive = activeSegment === seg . key ;
return (
< path
key = { seg . key }
d = { donutArcPath ( CX , CY , OUTER , INNER , seg . start , seg . end ) }
fill = { seg . color }
opacity = { hasActive ? ( isActive ? 1 : 0.25 ) : 0.88 }
stroke = { isActive ? 'rgba(255,255,255,0.6)' : 'none' }
strokeWidth = { isActive ? 2 : 0 }
style = { { cursor : 'pointer' , transition : 'opacity 0.2s' } }
onClick = { ( ) => onSegmentClick ( isActive ? null : seg . key ) }
/ >
) ;
} ) }
2026-03-13 12:50:15 -06:00
< text x = { CX } y = { CY - 10 } textAnchor = "middle" style = { { fontFamily : 'monospace' , fontSize : '22px' , fontWeight : '700' , fill : '#E2E8F0' } } >
{ total . toLocaleString ( ) }
< / t e x t >
< text x = { CX } y = { CY + 10 } textAnchor = "middle" style = { { fontFamily : 'monospace' , fontSize : '8.5px' , fontWeight : '600' , fill : '#475569' , letterSpacing : '0.12em' } } >
TOTAL
< / t e x t >
< / s v g >
2026-03-13 13:06:54 -06:00
{ /* Legend — always shows all 3 categories */ }
< div style = { { display : 'flex' , flexDirection : 'column' , gap : '0.6rem' } } >
{ segments . map ( ( seg ) => {
const isActive = activeSegment === seg . key ;
const dimmed = hasActive && ! isActive ;
return (
< div
key = { seg . key }
onClick = { ( ) => onSegmentClick ( isActive ? null : seg . key ) }
style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' , cursor : 'pointer' , opacity : dimmed ? 0.35 : 1 , transition : 'opacity 0.2s' } }
>
< div style = { { width : '8px' , height : '8px' , borderRadius : '2px' , background : seg . color , flexShrink : 0 , outline : isActive ? ` 2px solid ${ seg . color } ` : 'none' , outlineOffset : '1px' } } / >
< div >
< span style = { { fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' , color : '#64748B' , textTransform : 'uppercase' , letterSpacing : '0.06em' } } >
{ seg . label }
< / s p a n >
< span style = { { fontFamily : 'monospace' , fontSize : '0.8rem' , fontWeight : '700' , color : '#E2E8F0' , marginLeft : '0.4rem' } } >
{ seg . count }
< / s p a n >
< span style = { { fontFamily : 'monospace' , fontSize : '0.65rem' , color : '#475569' , marginLeft : '0.25rem' } } >
( { total > 0 ? ( ( seg . count / total ) * 100 ) . toFixed ( 0 ) : 0 } % )
< / s p a n >
< / d i v >
2026-03-13 12:50:15 -06:00
< / d i v >
2026-03-13 13:06:54 -06:00
) ;
} ) }
{ hasActive && (
< button
onClick = { ( ) => onSegmentClick ( null ) }
style = { { marginTop : '0.25rem' , background : 'none' , border : 'none' , fontFamily : 'monospace' , fontSize : '0.65rem' , color : '#475569' , cursor : 'pointer' , textAlign : 'left' , padding : 0 , textDecoration : 'underline' } }
>
clear filter
< / b u t t o n >
) }
2026-03-13 12:50:15 -06:00
< / d i v >
< / d i v >
) ;
}
2026-03-16 11:16:01 -06:00
// ---------------------------------------------------------------------------
// SVG Donut Chart — FP Workflow Status distribution
// ---------------------------------------------------------------------------
const FP _WORKFLOW _DEFS = [
{ key : 'Actionable' , label : 'Actionable' , color : '#F59E0B' } ,
{ key : 'Requested' , label : 'Requested' , color : '#0EA5E9' } ,
{ key : 'Reworked' , label : 'Reworked' , color : '#A855F7' } ,
{ key : 'Approved' , label : 'Approved' , color : '#22C55E' } ,
{ key : 'Rejected' , label : 'Rejected' , color : '#EF4444' } ,
{ key : 'Expired' , label : 'Expired' , color : '#64748B' } ,
{ key : 'Unknown' , label : 'Unknown' , color : '#334155' } ,
] ;
2026-03-16 12:13:13 -06:00
function FPWorkflowDonut ( { counts , total , centerLabel = 'FP TOTAL' } ) {
2026-03-16 11:16:01 -06:00
const SIZE = 180 ;
const CX = SIZE / 2 ;
const CY = SIZE / 2 ;
const OUTER = 72 ;
const INNER = 48 ;
if ( total === 0 ) {
return (
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'center' , height : ` ${ SIZE } px ` } } >
< p style = { { fontFamily : 'monospace' , fontSize : '0.75rem' , color : '#475569' } } > No FP workflows — click Sync to load < / p >
< / d i v >
) ;
}
let cursor = 0 ;
const segments = FP _WORKFLOW _DEFS . map ( ( def ) => {
const count = counts [ def . key ] || 0 ;
const start = cursor ;
const end = count > 0 ? cursor + ( count / total ) * 360 : cursor ;
if ( count > 0 ) cursor = end ;
return { ... def , count , start , end } ;
} ) . filter ( s => s . count > 0 ) ;
return (
< div style = { { display : 'flex' , alignItems : 'center' , gap : '2rem' } } >
< svg width = { SIZE } height = { SIZE } style = { { flexShrink : 0 } } >
< circle cx = { CX } cy = { CY } r = { OUTER + 1 } fill = "none" stroke = "rgba(10,18,32,0.8)" strokeWidth = "2" / >
{ segments . map ( ( seg ) => (
< path
key = { seg . key }
d = { donutArcPath ( CX , CY , OUTER , INNER , seg . start , seg . end ) }
fill = { seg . color }
opacity = { 0.88 }
/ >
) ) }
< text x = { CX } y = { CY - 10 } textAnchor = "middle" style = { { fontFamily : 'monospace' , fontSize : '22px' , fontWeight : '700' , fill : '#E2E8F0' } } >
{ total . toLocaleString ( ) }
< / t e x t >
< text x = { CX } y = { CY + 10 } textAnchor = "middle" style = { { fontFamily : 'monospace' , fontSize : '8.5px' , fontWeight : '600' , fill : '#475569' , letterSpacing : '0.12em' } } >
2026-03-16 12:13:13 -06:00
{ centerLabel }
2026-03-16 11:16:01 -06:00
< / t e x t >
< / s v g >
{ /* Legend */ }
< div style = { { display : 'flex' , flexDirection : 'column' , gap : '0.6rem' } } >
{ segments . map ( ( seg ) => (
< div key = { seg . key } style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' } } >
< div style = { { width : '8px' , height : '8px' , borderRadius : '2px' , background : seg . color , flexShrink : 0 } } / >
< div >
< span style = { { fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' , color : '#64748B' , textTransform : 'uppercase' , letterSpacing : '0.06em' } } >
{ seg . label }
< / s p a n >
< span style = { { fontFamily : 'monospace' , fontSize : '0.8rem' , fontWeight : '700' , color : '#E2E8F0' , marginLeft : '0.4rem' } } >
{ seg . count }
< / s p a n >
< span style = { { fontFamily : 'monospace' , fontSize : '0.65rem' , color : '#475569' , marginLeft : '0.25rem' } } >
( { ( ( seg . count / total ) * 100 ) . toFixed ( 0 ) } % )
< / s p a n >
< / d i v >
< / d i v >
) ) }
< / d i v >
< / d i v >
) ;
}
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
function SortIcon ( { colKey , sort } ) {
2026-03-11 12:47:11 -06:00
if ( sort . field !== colKey ) return < ChevronsUpDown style = { { width : '11px' , height : '11px' , opacity : 0.3 , marginLeft : '3px' , flexShrink : 0 } } / > ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
return sort . dir === 'asc'
2026-03-11 12:47:11 -06:00
? < ChevronUp style = { { width : '11px' , height : '11px' , color : '#0EA5E9' , marginLeft : '3px' , flexShrink : 0 } } / >
: < ChevronDown style = { { width : '11px' , height : '11px' , color : '#0EA5E9' , marginLeft : '3px' , flexShrink : 0 } } / > ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
}
2026-03-13 15:39:37 -06:00
// ---------------------------------------------------------------------------
// OverrideCell — inline editable hostname/dns with amber dot when overridden
// ---------------------------------------------------------------------------
function OverrideCell ( { findingId , field , originalValue , initialOverride , canWrite } ) {
const effective = initialOverride ? ? originalValue ? ? '' ;
const [ value , setValue ] = useState ( effective ) ;
const [ isOverridden , setOverridden ] = useState ( ! ! initialOverride ) ;
const [ editing , setEditing ] = useState ( false ) ;
const [ saving , setSaving ] = useState ( false ) ;
const lastSaved = useRef ( effective ) ;
const inputRef = useRef ( null ) ;
// Sync when the finding updates (e.g. after a full sync)
useEffect ( ( ) => {
const eff = initialOverride ? ? originalValue ? ? '' ;
setValue ( eff ) ;
setOverridden ( ! ! initialOverride ) ;
lastSaved . current = eff ;
} , [ initialOverride , originalValue ] ) ;
useEffect ( ( ) => {
if ( editing && inputRef . current ) inputRef . current . focus ( ) ;
} , [ editing ] ) ;
const persist = useCallback ( async ( newVal ) => {
const trimmed = newVal . trim ( ) ;
if ( trimmed === lastSaved . current ) return ;
setSaving ( true ) ;
try {
const res = await fetch ( ` ${ API _BASE } /ivanti/findings/ ${ findingId } /override ` , {
method : 'PUT' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { field , value : trimmed } ) ,
} ) ;
if ( res . ok ) {
const data = await res . json ( ) ;
const cleared = data . value === null ;
const displayed = cleared ? ( originalValue ? ? '' ) : trimmed ;
setValue ( displayed ) ;
setOverridden ( ! cleared ) ;
lastSaved . current = displayed ;
} else {
setValue ( lastSaved . current ) ; // revert on error
}
} catch {
setValue ( lastSaved . current ) ;
} finally {
setSaving ( false ) ;
}
} , [ findingId , field , originalValue ] ) ;
const handleBlur = ( ) => { setEditing ( false ) ; persist ( value ) ; } ;
const handleKeyDown = ( e ) => {
if ( e . key === 'Enter' ) { e . target . blur ( ) ; }
if ( e . key === 'Escape' ) { setValue ( lastSaved . current ) ; setEditing ( false ) ; }
} ;
const handleRevert = ( e ) => { e . stopPropagation ( ) ; setValue ( '' ) ; persist ( '' ) ; } ;
if ( editing ) {
return (
< td style = { { padding : '0.3rem 0.5rem' } } >
< input
ref = { inputRef }
value = { value }
onChange = { ( e ) => setValue ( e . target . value ) }
onBlur = { handleBlur }
onKeyDown = { handleKeyDown }
style = { {
width : '100%' , minWidth : '120px' ,
background : 'rgba(14,165,233,0.08)' ,
border : '1px solid rgba(14,165,233,0.4)' ,
borderRadius : '0.25rem' ,
padding : '0.2rem 0.4rem' ,
color : '#E2E8F0' , fontFamily : 'monospace' , fontSize : '0.72rem' ,
outline : 'none' ,
} }
/ >
< / t d >
) ;
}
return (
< td style = { { padding : '0.45rem 0.75rem' , whiteSpace : 'nowrap' } } >
< span
onClick = { canWrite ? ( ) => setEditing ( true ) : undefined }
title = { isOverridden ? ` Ivanti value: ${ originalValue || '—' } \n Click to edit ` : canWrite ? 'Click to edit' : undefined }
style = { {
display : 'inline-flex' , alignItems : 'center' , gap : '0.3rem' ,
color : isOverridden ? '#E2E8F0' : '#94A3B8' ,
fontFamily : 'monospace' , fontSize : '0.72rem' ,
cursor : canWrite ? 'text' : 'default' ,
} }
>
{ isOverridden && (
< span title = "Local override active" style = { { width : '5px' , height : '5px' , borderRadius : '50%' , background : '#F59E0B' , flexShrink : 0 , marginRight : '1px' } } / >
) }
{ value || '—' }
{ saving && < Loader style = { { width : '10px' , height : '10px' , color : '#475569' , animation : 'spin 1s linear infinite' , flexShrink : 0 } } / > }
{ isOverridden && canWrite && ! saving && (
< button
onClick = { handleRevert }
title = "Revert to Ivanti value"
style = { { background : 'none' , border : 'none' , padding : '0 1px' , cursor : 'pointer' , color : '#475569' , lineHeight : 1 , flexShrink : 0 , display : 'inline-flex' , alignItems : 'center' } }
>
< RotateCcw style = { { width : '10px' , height : '10px' } } / >
< / b u t t o n >
) }
< / s p a n >
< / t d >
) ;
}
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
// ---------------------------------------------------------------------------
// NoteCell — inline editable, saves on blur
// ---------------------------------------------------------------------------
function NoteCell ( { findingId , initialNote } ) {
2026-03-11 12:47:11 -06:00
const [ value , setValue ] = useState ( initialNote || '' ) ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
const [ saving , setSaving ] = useState ( false ) ;
2026-03-11 12:47:11 -06:00
const lastSaved = useRef ( initialNote || '' ) ;
useEffect ( ( ) => {
setValue ( initialNote || '' ) ;
lastSaved . current = initialNote || '' ;
} , [ initialNote ] ) ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
const save = useCallback ( async ( ) => {
2026-03-11 12:47:11 -06:00
if ( value === lastSaved . current ) return ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
setSaving ( true ) ;
try {
await fetch ( ` ${ API _BASE } /ivanti/findings/ ${ encodeURIComponent ( findingId ) } /note ` , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' } ,
credentials : 'include' ,
body : JSON . stringify ( { note : value } )
} ) ;
2026-03-11 12:47:11 -06:00
lastSaved . current = value ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
} catch ( e ) {
console . error ( 'Failed to save note:' , e ) ;
} finally {
setSaving ( false ) ;
}
2026-03-11 12:47:11 -06:00
} , [ findingId , value ] ) ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
return (
< div style = { { position : 'relative' } } >
< input
type = "text"
value = { value }
maxLength = { 255 }
onChange = { ( e ) => setValue ( e . target . value ) }
onBlur = { save }
placeholder = "Add note…"
style = { {
2026-03-11 12:47:11 -06:00
width : '100%' , minWidth : '160px' ,
background : 'rgba(14,165,233,0.05)' ,
border : '1px solid rgba(14,165,233,0.2)' ,
borderRadius : '4px' , padding : '4px 8px' ,
color : '#CBD5E1' , fontSize : '0.75rem' ,
fontFamily : 'inherit' , outline : 'none' , boxSizing : 'border-box'
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
} }
2026-03-11 12:47:11 -06:00
onFocus = { ( e ) => { e . target . style . borderColor = 'rgba(14,165,233,0.6)' ; e . target . style . background = 'rgba(14,165,233,0.1)' ; } }
onBlurCapture = { ( e ) => { e . target . style . borderColor = 'rgba(14,165,233,0.2)' ; e . target . style . background = 'rgba(14,165,233,0.05)' ; } }
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
/ >
2026-03-11 12:47:11 -06:00
{ saving && < Loader style = { { width : '10px' , height : '10px' , position : 'absolute' , right : '6px' , top : '6px' , color : '#0EA5E9' } } / > }
< / d i v >
) ;
}
// ---------------------------------------------------------------------------
// ColumnManager — popover with drag-to-reorder and show/hide toggles
// ---------------------------------------------------------------------------
function ColumnManager ( { columnOrder , onChange } ) {
2026-03-11 13:03:17 -06:00
const [ open , setOpen ] = useState ( false ) ;
const [ dragIdx , setDragIdx ] = useState ( null ) ;
const [ overIdx , setOverIdx ] = useState ( null ) ;
const panelRef = useRef ( null ) ;
const btnRef = useRef ( null ) ;
2026-03-11 12:47:11 -06:00
useEffect ( ( ) => {
if ( ! open ) return ;
const handler = ( e ) => {
2026-03-11 13:03:17 -06:00
if ( ! panelRef . current ? . contains ( e . target ) && ! btnRef . current ? . contains ( e . target ) ) setOpen ( false ) ;
2026-03-11 12:47:11 -06:00
} ;
document . addEventListener ( 'mousedown' , handler ) ;
return ( ) => document . removeEventListener ( 'mousedown' , handler ) ;
} , [ open ] ) ;
const toggleVisible = ( key ) => {
2026-03-11 13:03:17 -06:00
onChange ( columnOrder . map ( ( c ) => c . key === key ? { ... c , visible : ! c . visible } : c ) ) ;
2026-03-11 12:47:11 -06:00
} ;
const handleDragStart = ( idx ) => setDragIdx ( idx ) ;
const handleDragOver = ( e , idx ) => { e . preventDefault ( ) ; setOverIdx ( idx ) ; } ;
const handleDrop = ( idx ) => {
if ( dragIdx === null || dragIdx === idx ) { setDragIdx ( null ) ; setOverIdx ( null ) ; return ; }
const updated = [ ... columnOrder ] ;
const [ moved ] = updated . splice ( dragIdx , 1 ) ;
updated . splice ( idx , 0 , moved ) ;
onChange ( updated ) ;
setDragIdx ( null ) ;
setOverIdx ( null ) ;
} ;
const visibleCount = columnOrder . filter ( ( c ) => c . visible ) . length ;
return (
< div style = { { position : 'relative' } } >
< button
ref = { btnRef }
onClick = { ( ) => setOpen ( ( p ) => ! p ) }
style = { {
display : 'flex' , alignItems : 'center' , gap : '0.375rem' ,
padding : '0.375rem 0.75rem' ,
background : open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.07)' ,
border : ` 1px solid rgba(14,165,233, ${ open ? '0.5' : '0.25' } ) ` ,
borderRadius : '0.375rem' ,
color : '#0EA5E9' , cursor : 'pointer' ,
fontFamily : 'monospace' , fontSize : '0.75rem' , fontWeight : '600' ,
textTransform : 'uppercase' , letterSpacing : '0.05em'
} }
>
< Settings2 style = { { width : '13px' , height : '13px' } } / >
Columns
< span style = { { fontSize : '0.65rem' , opacity : 0.7 } } > ( { visibleCount } / { columnOrder . length } ) < / s p a n >
< / b u t t o n >
{ open && (
< div
ref = { panelRef }
style = { {
position : 'absolute' , top : 'calc(100% + 8px)' , right : 0 ,
width : '220px' , zIndex : 100 ,
background : 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)' ,
border : '1px solid rgba(14,165,233,0.25)' ,
borderRadius : '0.5rem' ,
boxShadow : '0 8px 24px rgba(0,0,0,0.6)' ,
padding : '0.5rem'
} }
>
< div style = { { fontSize : '0.65rem' , color : '#475569' , fontFamily : 'monospace' , textTransform : 'uppercase' , letterSpacing : '0.1em' , padding : '0.25rem 0.5rem 0.5rem' , borderBottom : '1px solid rgba(255,255,255,0.05)' , marginBottom : '0.375rem' } } >
Drag to reorder · click to toggle
< / d i v >
{ columnOrder . map ( ( col , idx ) => {
2026-03-11 13:03:17 -06:00
const def = COLUMN _DEFS [ col . key ] ;
2026-03-11 12:47:11 -06:00
const isDragging = dragIdx === idx ;
const isOver = overIdx === idx && dragIdx !== null && dragIdx !== idx ;
return (
< div
key = { col . key }
draggable
onDragStart = { ( ) => handleDragStart ( idx ) }
onDragOver = { ( e ) => handleDragOver ( e , idx ) }
onDrop = { ( ) => handleDrop ( idx ) }
onDragEnd = { ( ) => { setDragIdx ( null ) ; setOverIdx ( null ) ; } }
style = { {
display : 'flex' , alignItems : 'center' , gap : '0.5rem' ,
2026-03-11 13:03:17 -06:00
padding : '0.4rem 0.5rem' , borderRadius : '0.25rem' , cursor : 'grab' ,
2026-03-11 12:47:11 -06:00
opacity : isDragging ? 0.4 : 1 ,
background : isOver ? 'rgba(14,165,233,0.12)' : 'transparent' ,
borderTop : isOver ? '2px solid #0EA5E9' : '2px solid transparent' ,
transition : 'background 0.1s'
} }
>
< GripVertical style = { { width : '14px' , height : '14px' , color : '#334155' , flexShrink : 0 } } / >
< span style = { { flex : 1 , fontSize : '0.78rem' , color : col . visible ? '#CBD5E1' : '#475569' , fontFamily : 'monospace' } } >
{ def ? . label || col . key }
< / s p a n >
< button
onClick = { ( e ) => { e . stopPropagation ( ) ; toggleVisible ( col . key ) ; } }
style = { { background : 'none' , border : 'none' , cursor : 'pointer' , padding : '2px' , color : col . visible ? '#0EA5E9' : '#334155' , lineHeight : 1 } }
>
2026-03-11 13:03:17 -06:00
{ col . visible ? < Eye style = { { width : '14px' , height : '14px' } } / > : < EyeOff style = { { width : '14px' , height : '14px' } } / > }
2026-03-11 12:47:11 -06:00
< / b u t t o n >
< / d i v >
) ;
} ) }
< / d i v >
) }
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
< / d i v >
) ;
}
2026-03-11 13:03:17 -06:00
// ---------------------------------------------------------------------------
// FilterDropdown — portal-based so it escapes overflow:auto clipping
// ---------------------------------------------------------------------------
function FilterDropdown ( { anchorEl , colKey , findings , activeFilter , onFilterChange , onClose } ) {
const [ pos , setPos ] = useState ( { top : 0 , left : 0 } ) ;
const [ search , setSearch ] = useState ( '' ) ;
const panelRef = useRef ( null ) ;
const inputRef = useRef ( null ) ;
// Compute fixed position from anchor button's viewport rect
useEffect ( ( ) => {
if ( ! anchorEl ) return ;
const r = anchorEl . getBoundingClientRect ( ) ;
setPos ( { top : r . bottom + 4 , left : r . left } ) ;
setTimeout ( ( ) => inputRef . current ? . focus ( ) , 0 ) ;
} , [ anchorEl ] ) ;
// Close on outside click
useEffect ( ( ) => {
const handler = ( e ) => {
if ( panelRef . current && ! panelRef . current . contains ( e . target ) &&
! ( anchorEl && anchorEl . contains ( e . target ) ) ) {
onClose ( ) ;
}
} ;
document . addEventListener ( 'mousedown' , handler ) ;
return ( ) => document . removeEventListener ( 'mousedown' , handler ) ;
} , [ anchorEl , onClose ] ) ;
// Close on Escape
useEffect ( ( ) => {
const handler = ( e ) => { if ( e . key === 'Escape' ) onClose ( ) ; } ;
document . addEventListener ( 'keydown' , handler ) ;
return ( ) => document . removeEventListener ( 'keydown' , handler ) ;
} , [ onClose ] ) ;
2026-03-11 13:17:01 -06:00
// Unique values from the full (unfiltered) findings list.
// Multi-value columns (e.g. cves) expand their array so each item is a separate option.
2026-03-16 13:27:16 -06:00
// EMPTY_SENTINEL is prepended when any finding has a blank/null cell.
2026-03-11 13:03:17 -06:00
const allValues = useMemo ( ( ) => {
2026-03-11 13:17:01 -06:00
const def = COLUMN _DEFS [ colKey ] ;
2026-03-11 13:03:17 -06:00
const vals = new Set ( ) ;
2026-03-16 13:27:16 -06:00
let hasEmpty = false ;
2026-03-11 13:03:17 -06:00
findings . forEach ( ( f ) => {
2026-03-11 13:17:01 -06:00
if ( def ? . multiValue ) {
2026-03-16 13:27:16 -06:00
const arr = f [ colKey ] || [ ] ;
if ( arr . length === 0 ) { hasEmpty = true ; return ; }
arr . forEach ( ( v ) => { if ( String ( v ) . trim ( ) ) vals . add ( String ( v ) . trim ( ) ) ; } ) ;
2026-03-11 13:17:01 -06:00
} else {
const v = getFilterVal ( f , colKey ) . trim ( ) ;
2026-03-16 13:27:16 -06:00
if ( v ) vals . add ( v ) ; else hasEmpty = true ;
2026-03-11 13:17:01 -06:00
}
2026-03-11 13:03:17 -06:00
} ) ;
2026-03-16 13:27:16 -06:00
const sorted = [ ... vals ] . sort ( ( a , b ) => a . localeCompare ( b , undefined , { numeric : true } ) ) ;
if ( hasEmpty ) sorted . unshift ( EMPTY _SENTINEL ) ;
return sorted ;
2026-03-11 13:03:17 -06:00
} , [ findings , colKey ] ) ;
const displayed = search . trim ( )
2026-03-16 13:27:16 -06:00
? allValues . filter ( ( v ) => v === EMPTY _SENTINEL || v . toLowerCase ( ) . includes ( search . toLowerCase ( ) ) )
2026-03-11 13:03:17 -06:00
: allValues ;
const isChecked = ( val ) => ! activeFilter || activeFilter . has ( val ) ;
const activeCount = activeFilter ? activeFilter . size : allValues . length ;
const toggle = ( val ) => {
let next ;
if ( ! activeFilter ) {
next = new Set ( allValues ) ;
next . delete ( val ) ;
} else {
next = new Set ( activeFilter ) ;
if ( next . has ( val ) ) next . delete ( val ) ; else next . add ( val ) ;
}
// If all values selected again, remove the filter entirely
onFilterChange ( next . size >= allValues . length ? null : next ) ;
} ;
return ReactDOM . createPortal (
< div
ref = { panelRef }
style = { {
position : 'fixed' , top : pos . top , left : pos . left ,
width : '220px' , zIndex : 9999 ,
background : 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)' ,
border : '1px solid rgba(14,165,233,0.3)' ,
borderRadius : '0.5rem' ,
boxShadow : '0 8px 32px rgba(0,0,0,0.8)' ,
padding : '0.5rem' ,
} }
>
{ /* Search */ }
< input
ref = { inputRef }
type = "text"
value = { search }
onChange = { ( e ) => setSearch ( e . target . value ) }
placeholder = "Search values…"
style = { {
width : '100%' , marginBottom : '0.375rem' ,
background : 'rgba(14,165,233,0.05)' ,
border : '1px solid rgba(14,165,233,0.2)' ,
borderRadius : '0.25rem' , padding : '0.3rem 0.5rem' ,
color : '#CBD5E1' , fontSize : '0.72rem' ,
fontFamily : 'monospace' , outline : 'none' , boxSizing : 'border-box' ,
} }
/ >
{ /* Select All / Clear */ }
< div style = { { display : 'flex' , gap : '0.375rem' , marginBottom : '0.375rem' , paddingBottom : '0.375rem' , borderBottom : '1px solid rgba(255,255,255,0.06)' } } >
< button
onClick = { ( ) => onFilterChange ( null ) }
style = { { flex : 1 , padding : '0.2rem' , background : 'rgba(14,165,233,0.08)' , border : '1px solid rgba(14,165,233,0.2)' , borderRadius : '0.25rem' , color : '#0EA5E9' , cursor : 'pointer' , fontSize : '0.65rem' , fontFamily : 'monospace' , textTransform : 'uppercase' , letterSpacing : '0.05em' } }
>
Select All
< / b u t t o n >
< button
onClick = { ( ) => onFilterChange ( new Set ( ) ) }
style = { { flex : 1 , padding : '0.2rem' , background : 'rgba(239,68,68,0.08)' , border : '1px solid rgba(239,68,68,0.2)' , borderRadius : '0.25rem' , color : '#EF4444' , cursor : 'pointer' , fontSize : '0.65rem' , fontFamily : 'monospace' , textTransform : 'uppercase' , letterSpacing : '0.05em' } }
>
Clear
< / b u t t o n >
< / d i v >
{ /* Value checkboxes */ }
< div style = { { maxHeight : '200px' , overflowY : 'auto' } } >
{ displayed . length === 0 ? (
< div style = { { fontSize : '0.68rem' , color : '#475569' , textAlign : 'center' , padding : '0.5rem 0' } } > No values < / d i v >
) : displayed . map ( ( val ) => (
< label
key = { val }
style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' , padding : '0.25rem 0.375rem' , borderRadius : '0.25rem' , cursor : 'pointer' , color : isChecked ( val ) ? '#CBD5E1' : '#475569' , fontSize : '0.72rem' , fontFamily : 'monospace' } }
onMouseEnter = { ( e ) => e . currentTarget . style . background = 'rgba(14,165,233,0.08)' }
onMouseLeave = { ( e ) => e . currentTarget . style . background = 'transparent' }
>
< input
type = "checkbox"
checked = { isChecked ( val ) }
onChange = { ( ) => toggle ( val ) }
style = { { accentColor : '#0EA5E9' , width : '12px' , height : '12px' , flexShrink : 0 , cursor : 'pointer' } }
/ >
2026-03-16 13:27:16 -06:00
{ val === EMPTY _SENTINEL
? < span style = { { fontStyle : 'italic' , color : '#64748B' , whiteSpace : 'nowrap' } } > — empty — < / s p a n >
: < span style = { { overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' } } > { val } < / s p a n >
}
2026-03-11 13:03:17 -06:00
< / l a b e l >
) ) }
< / d i v >
{ /* Status footer */ }
< div style = { { marginTop : '0.375rem' , paddingTop : '0.375rem' , borderTop : '1px solid rgba(255,255,255,0.06)' , fontSize : '0.65rem' , color : '#475569' , textAlign : 'center' , fontFamily : 'monospace' } } >
{ activeCount } / { allValues . length } selected
< / d i v >
< / d i v > ,
document . body
) ;
}
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
// ---------------------------------------------------------------------------
2026-03-11 12:47:11 -06:00
// Render a single table cell by column key
// ---------------------------------------------------------------------------
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
function TableCell ( { colKey , finding , canWrite , onCveMouseEnter , onCveMouseLeave , fpSubmissions , onEditSubmission } ) {
2026-03-11 12:47:11 -06:00
switch ( colKey ) {
2026-03-11 14:23:50 -06:00
case 'findingId' :
return (
< td style = { { padding : '0.45rem 0.75rem' , whiteSpace : 'nowrap' , color : '#475569' , fontFamily : 'monospace' , fontSize : '0.68rem' } } >
{ finding . id || '—' }
< / t d >
) ;
2026-03-11 12:47:11 -06:00
case 'severity' : {
const sc = severityColor ( finding . vrrGroup ) ;
return (
< td style = { { padding : '0.45rem 0.75rem' , whiteSpace : 'nowrap' } } >
< span style = { { display : 'inline-flex' , alignItems : 'center' , gap : '0.3rem' , padding : '0.2rem 0.45rem' , borderRadius : '0.25rem' , background : sc . bg , border : ` 1px solid ${ sc . border } 40 ` , fontFamily : 'monospace' , fontWeight : '700' , color : sc . text , fontSize : '0.72rem' } } >
{ finding . severity ? . toFixed ( 2 ) }
< span style = { { fontSize : '0.6rem' , opacity : 0.75 } } > { finding . vrrGroup } < / s p a n >
< / s p a n >
< / t d >
) ;
}
case 'title' :
return (
< td style = { { padding : '0.45rem 0.75rem' , maxWidth : '280px' } } >
< span style = { { color : '#CBD5E1' , display : 'block' , overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' } } title = { finding . title } >
{ finding . title }
< / s p a n >
< / t d >
) ;
2026-03-11 13:17:01 -06:00
case 'cves' : {
const cves = finding . cves || [ ] ;
if ( cves . length === 0 ) return < td style = { { padding : '0.45rem 0.75rem' , color : '#475569' } } > — < / t d > ;
const shown = cves . slice ( 0 , 2 ) ;
const rest = cves . length - shown . length ;
return (
< td style = { { padding : '0.45rem 0.75rem' , minWidth : '160px' , maxWidth : '240px' } } >
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '0.2rem' } } >
{ shown . map ( ( cve ) => (
2026-04-09 14:42:23 -06:00
< span
key = { cve }
onMouseEnter = { onCveMouseEnter ? ( e ) => onCveMouseEnter ( cve , e ) : undefined }
onMouseLeave = { onCveMouseLeave || undefined }
style = { { padding : '0.1rem 0.35rem' , borderRadius : '0.2rem' , background : 'rgba(139,92,246,0.1)' , border : '1px solid rgba(139,92,246,0.3)' , color : '#A78BFA' , fontFamily : 'monospace' , fontSize : '0.65rem' , fontWeight : '600' , whiteSpace : 'nowrap' } }
>
2026-03-11 13:17:01 -06:00
{ cve }
< / s p a n >
) ) }
{ rest > 0 && (
< span title = { cves . slice ( 2 ) . join ( '\n' ) } style = { { padding : '0.1rem 0.35rem' , borderRadius : '0.2rem' , background : 'rgba(100,116,139,0.12)' , border : '1px solid rgba(100,116,139,0.25)' , color : '#64748B' , fontFamily : 'monospace' , fontSize : '0.65rem' , cursor : 'help' , whiteSpace : 'nowrap' } } >
+ { rest } more
< / s p a n >
) }
< / d i v >
< / t d >
) ;
}
2026-03-11 12:47:11 -06:00
case 'hostName' :
2026-03-13 15:39:37 -06:00
return (
< OverrideCell
findingId = { finding . id }
field = "hostName"
originalValue = { finding . hostName }
initialOverride = { finding . overrides ? . hostName ? ? null }
canWrite = { canWrite }
/ >
) ;
2026-03-11 12:47:11 -06:00
case 'ipAddress' :
return (
< td style = { { padding : '0.45rem 0.75rem' , whiteSpace : 'nowrap' , color : '#94A3B8' , fontFamily : 'monospace' , fontSize : '0.72rem' } } >
2026-03-13 15:39:37 -06:00
{ finding . ipAddress || '—' }
2026-03-11 12:47:11 -06:00
< / t d >
) ;
case 'dns' :
return (
2026-03-13 15:39:37 -06:00
< OverrideCell
findingId = { finding . id }
field = "dns"
originalValue = { finding . dns }
initialOverride = { finding . overrides ? . dns ? ? null }
canWrite = { canWrite }
/ >
2026-03-11 12:47:11 -06:00
) ;
case 'dueDate' : {
const color = dueDateColor ( finding . dueDate ) ;
return (
< td style = { { padding : '0.45rem 0.75rem' , whiteSpace : 'nowrap' , fontFamily : 'monospace' , fontSize : '0.72rem' , fontWeight : '600' , color } } >
{ finding . dueDate || '—' }
< / t d >
) ;
}
case 'slaStatus' :
return (
< td style = { { padding : '0.45rem 0.75rem' , whiteSpace : 'nowrap' , fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' , color : slaColor ( finding . slaStatus ) } } >
{ finding . slaStatus || '—' }
< / t d >
) ;
2026-03-11 13:03:17 -06:00
case 'buOwnership' : {
const bu = finding . buOwnership || '' ;
const isSteam = bu . toUpperCase ( ) . includes ( 'STEAM' ) ;
return (
< td style = { { padding : '0.45rem 0.75rem' , whiteSpace : 'nowrap' } } >
{ bu ? (
< span
title = { bu }
style = { {
display : 'inline-block' , padding : '0.15rem 0.4rem' ,
borderRadius : '0.25rem' ,
background : isSteam ? 'rgba(14,165,233,0.1)' : 'rgba(245,158,11,0.1)' ,
border : ` 1px solid ${ isSteam ? 'rgba(14,165,233,0.3)' : 'rgba(245,158,11,0.3)' } ` ,
color : isSteam ? '#0EA5E9' : '#F59E0B' ,
fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' ,
} }
>
{ bu . replace ( 'NTS-AEO-' , '' ) }
< / s p a n >
) : (
< span style = { { color : '#475569' } } > — < / s p a n >
) }
< / t d >
) ;
}
2026-03-11 14:44:53 -06:00
case 'workflow' : {
const wf = finding . workflow ;
if ( ! wf || ! wf . id ) return < td style = { { padding : '0.45rem 0.75rem' , color : '#334155' } } > — < / t d > ;
const ws = workflowStyle ( wf . state ) ;
return (
< td style = { { padding : '0.45rem 0.75rem' , whiteSpace : 'nowrap' } } >
< span
2026-04-13 12:39:47 -06:00
title = { ` ${ wf . id } · ${ wf . state || 'Unknown' } · ${ wf . type || '' } ` }
2026-03-11 14:44:53 -06:00
style = { {
display : 'inline-flex' , alignItems : 'center' , gap : '0.3rem' ,
padding : '0.15rem 0.45rem' , borderRadius : '0.25rem' ,
background : ws . bg , border : ` 1px solid ${ ws . border } ` ,
fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' ,
2026-04-13 12:39:47 -06:00
color : ws . text , cursor : 'default' ,
2026-03-11 14:44:53 -06:00
} }
>
{ wf . id }
< span style = { { fontSize : '0.58rem' , opacity : 0.8 , textTransform : 'uppercase' , letterSpacing : '0.04em' } } >
{ wf . state }
< / s p a n >
< / s p a n >
< / t d >
) ;
}
2026-03-11 12:47:11 -06:00
case 'lastFoundOn' :
return (
< td style = { { padding : '0.45rem 0.75rem' , whiteSpace : 'nowrap' , color : '#64748B' , fontFamily : 'monospace' , fontSize : '0.72rem' } } >
{ finding . lastFoundOn || '—' }
< / t d >
) ;
case 'note' :
return (
< td style = { { padding : '0.45rem 0.75rem' } } >
< NoteCell findingId = { finding . id } initialNote = { finding . note } / >
< / t d >
) ;
default :
return < td style = { { padding : '0.45rem 0.75rem' , color : '#64748B' } } > — < / t d > ;
}
}
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
// ---------------------------------------------------------------------------
// 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 ;
2026-03-26 14:46:59 -06:00
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 } ) ;
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
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 ] ) ;
2026-04-14 15:33:19 -06:00
const isCard = queueForm . workflowType === 'CARD' || queueForm . workflowType === 'GRANITE' ;
2026-03-26 14:52:06 -06:00
const canSubmit = isCard || queueForm . vendor . trim ( ) . length > 0 ;
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
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
< / d i v >
< div style = { { fontFamily : 'monospace' , fontSize : '0.68rem' , color : '#94A3B8' , marginBottom : '0.75rem' , overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' } } title = { finding . id } >
{ finding . id }
< / d i v >
2026-03-26 14:52:06 -06:00
{ /* 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
< / d i v >
) : (
< 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
< / s p a n >
< 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 ( ) ; } }
/ >
< / l a b e l >
) }
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
{ /* 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
< / s p a n >
< div style = { { display : 'flex' , gap : '0.375rem' } } >
2026-03-26 14:46:59 -06:00
{ [
2026-04-14 15:33:19 -06:00
{ key : 'FP' , col : '#F59E0B' , rgb : '245,158,11' } ,
{ key : 'Archer' , col : '#0EA5E9' , rgb : '14,165,233' } ,
{ key : 'CARD' , col : '#10B981' , rgb : '16,185,129' } ,
{ key : 'GRANITE' , col : '#A1887F' , rgb : '161,136,127' } ,
2026-03-26 14:46:59 -06:00
] . map ( ( { key , col , rgb } ) => {
const active = queueForm . workflowType === key ;
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
return (
< button
2026-03-26 14:46:59 -06:00
key = { key }
onClick = { ( ) => setQueueForm ( ( f ) => ( { ... f , workflowType : key } ) ) }
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
style = { {
flex : 1 , padding : '0.3rem' ,
2026-03-26 14:46:59 -06:00
background : active ? ` rgba( ${ rgb } ,0.15) ` : 'transparent' ,
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
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' ,
} }
>
2026-03-26 14:46:59 -06:00
{ key }
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
< / b u t t o n >
) ;
} ) }
< / d i v >
< / d i v >
{ /* 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
< / b u t t o n >
< button
onClick = { onCancel }
style = { {
padding : '0.4rem 0.625rem' ,
background : 'none' , border : 'none' ,
color : '#475569' , fontFamily : 'monospace' , fontSize : '0.72rem' ,
cursor : 'pointer' ,
} }
>
Cancel
< / b u t t o n >
< / d i v >
< / d i v > ,
document . body
) ;
}
// ---------------------------------------------------------------------------
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
// ---------------------------------------------------------------------------
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
function QueuePanel ( { open , items , onClose , onUpdate , onDelete , onDeleteMany , onClearCompleted , onCreateFpWorkflow , onRedirectComplete , canWrite , fpSubmissions , onEditSubmission } ) {
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
const pendingCount = items . filter ( ( i ) => i . status === 'pending' ) . length ;
const completedCount = items . filter ( ( i ) => i . status === 'complete' ) . length ;
2026-03-26 15:43:43 -06:00
const [ selectedIds , setSelectedIds ] = useState ( new Set ( ) ) ;
2026-04-09 16:01:36 -06:00
const [ redirectItem , setRedirectItem ] = useState ( null ) ;
const [ redirectSuccess , setRedirectSuccess ] = useState ( null ) ;
2026-03-26 15:43:43 -06:00
// 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 ( ) ) ;
} ;
2026-04-09 16:01:36 -06:00
const handleRedirectSuccess = ( newItem ) => {
if ( onRedirectComplete ) onRedirectComplete ( newItem ) ;
setRedirectItem ( null ) ;
setRedirectSuccess ( ` Redirected to ${ newItem . workflow _type } ` ) ;
setTimeout ( ( ) => setRedirectSuccess ( null ) , 3000 ) ;
} ;
2026-04-14 15:33:19 -06:00
// Render a single queue item row
const renderQueueItem = ( item , { done , selectedIds , toggleSelect , onUpdate , onDelete , setRedirectItem , canWrite } ) => {
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' }
: { 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' ;
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 }
< / d i v >
{ isInventoryItem ? (
< >
{ item . hostname && (
< div style = { {
fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' ,
color : done ? '#334155' : '#94A3B8' ,
textDecoration : done ? 'line-through' : 'none' ,
marginTop : '2px' ,
} } >
{ item . hostname }
< / d i v >
) }
{ 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 }
< / d i v >
) }
< / >
) : (
< >
{ 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 }
< / d i v >
) }
{ item . hostname && (
< div style = { {
fontFamily : 'monospace' , fontSize : '0.62rem' ,
color : done ? '#334155' : '#94A3B8' ,
textDecoration : done ? 'line-through' : 'none' ,
marginTop : '1px' ,
} } >
{ item . hostname }
< / d i v >
) }
{ item . ip _address && (
< div style = { {
fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' ,
color : done ? '#334155' : '#10B981' ,
textDecoration : done ? 'line-through' : 'none' ,
marginTop : '1px' ,
} } >
{ item . ip _address }
< / d i v >
) }
< / >
) }
< / d i v >
{ /* 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 }
< / s p a n >
{ /* Redirect button — completed items only */ }
{ canWrite && done && (
< button
onClick = { ( ) => setRedirectItem ( item ) }
style = { { background : 'none' , border : 'none' , cursor : 'pointer' , color : '#334155' , padding : '1px' , lineHeight : 1 , flexShrink : 0 } }
onMouseEnter = { ( e ) => e . currentTarget . style . color = '#0EA5E9' }
onMouseLeave = { ( e ) => e . currentTarget . style . color = '#334155' }
title = "Redirect to another workflow"
>
< CornerUpRight style = { { width : '13px' , height : '13px' } } / >
< / b u t t o n >
) }
{ /* 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' } } / >
< / b u t t o n >
< / d i v >
) ;
} ;
// Inventory items (CARD + GRANITE) are their own top section; everything else groups by vendor
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
const grouped = useMemo ( ( ) => {
2026-04-14 15:33:19 -06:00
const inventoryItems = items . filter ( ( i ) => i . workflow _type === 'CARD' || i . workflow _type === 'GRANITE' ) ;
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' ) ;
2026-03-26 14:52:06 -06:00
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
const map = { } ;
2026-03-26 14:52:06 -06:00
otherItems . forEach ( ( item ) => {
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
const v = item . vendor || 'Unknown' ;
if ( ! map [ v ] ) map [ v ] = [ ] ;
map [ v ] . push ( item ) ;
} ) ;
2026-03-26 14:52:06 -06:00
const vendorGroups = Object . keys ( map ) . sort ( ) . map ( ( vendor ) => ( {
2026-04-14 15:33:19 -06:00
key : vendor , label : vendor , items : map [ vendor ] , isInventory : false ,
2026-03-26 14:52:06 -06:00
} ) ) ;
2026-04-14 15:33:19 -06:00
return inventoryItems . length > 0
? [ { key : '__INVENTORY__' , label : 'Inventory' , cardItems , graniteItems , items : inventoryItems , isInventory : true } , ... vendorGroups ]
2026-03-26 14:52:06 -06:00
: vendorGroups ;
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
} , [ 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
< / s p a n >
{ 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 }
< / s p a n >
) }
< / d i v >
< button
onClick = { onClose }
style = { { background : 'none' , border : 'none' , cursor : 'pointer' , color : '#475569' , padding : '4px' , lineHeight : 1 } }
>
< X style = { { width : '18px' , height : '18px' } } / >
< / b u t t o n >
< / d i v >
{ /* 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 .
< / s p a n >
< / d i v >
2026-04-14 15:33:19 -06:00
) : grouped . map ( ( { key , label , items : groupItems , isInventory , cardItems , graniteItems } ) => (
2026-03-26 14:52:06 -06:00
< div key = { key } style = { { marginBottom : '1.25rem' } } >
{ /* Group header */ }
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
< div style = { {
display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' ,
padding : '0.3rem 0' , marginBottom : '0.375rem' ,
2026-04-14 15:33:19 -06:00
borderBottom : ` 1px solid ${ isInventory ? 'rgba(16,185,129,0.2)' : 'rgba(255,255,255,0.06)' } ` ,
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
} } >
2026-04-14 15:33:19 -06:00
< span style = { { fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '700' , color : isInventory ? '#10B981' : '#94A3B8' , textTransform : 'uppercase' , letterSpacing : '0.1em' } } >
2026-03-26 14:52:06 -06:00
{ label }
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
< / s p a n >
< span style = { { fontFamily : 'monospace' , fontSize : '0.62rem' , color : '#334155' } } >
2026-03-26 14:52:06 -06:00
{ groupItems . length }
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
< / s p a n >
< / d i v >
2026-04-14 15:33:19 -06:00
{ /* Items — Inventory section renders CARD then GRANITE with optional sub-divider */ }
{ isInventory ? (
< >
{ cardItems . map ( ( item ) => renderQueueItem ( item , { done : item . status === 'complete' , selectedIds , toggleSelect , onUpdate , onDelete , setRedirectItem , canWrite } ) ) }
{ cardItems . length > 0 && graniteItems . length > 0 && (
< div style = { {
height : '1px' ,
background : 'rgba(161,136,127,0.18)' ,
margin : '0.5rem 0.625rem' ,
} } / >
) }
{ graniteItems . 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 } ) )
) }
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
< / d i v >
) ) }
< / d i v >
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
{ /* Submissions section */ }
{ fpSubmissions && fpSubmissions . length > 0 && (
< div style = { { padding : '0 1.25rem 0.75rem' } } >
< div style = { {
display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' ,
padding : '0.3rem 0' , marginBottom : '0.375rem' ,
borderBottom : '1px solid rgba(245,158,11,0.2)' ,
} } >
< span style = { { fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '700' , color : '#F59E0B' , textTransform : 'uppercase' , letterSpacing : '0.1em' } } >
Submissions
< / s p a n >
< span style = { { fontFamily : 'monospace' , fontSize : '0.62rem' , color : '#334155' } } >
{ fpSubmissions . length }
< / s p a n >
< / d i v >
{ fpSubmissions . map ( ( sub ) => {
const lsBadge = lifecycleStatusBadge ( sub . lifecycle _status ) ;
const findingCount = ( ( ) => {
try { return JSON . parse ( sub . finding _ids _json || '[]' ) . length ; } catch { return 0 ; }
} ) ( ) ;
const clickable = canWrite && onEditSubmission ;
return (
< div
key = { sub . id }
onClick = { clickable ? ( ) => onEditSubmission ( sub ) : undefined }
style = { {
display : 'flex' , alignItems : 'center' , gap : '0.5rem' ,
padding : '0.45rem 0.625rem' ,
marginBottom : '0.25rem' ,
borderRadius : '0.375rem' ,
background : 'rgba(245,158,11,0.04)' ,
border : '1px solid rgba(245,158,11,0.1)' ,
cursor : clickable ? 'pointer' : 'default' ,
transition : 'all 0.15s' ,
} }
onMouseEnter = { clickable ? ( e ) => {
e . currentTarget . style . borderColor = 'rgba(245,158,11,0.3)' ;
e . currentTarget . style . background = 'rgba(245,158,11,0.08)' ;
} : undefined }
onMouseLeave = { clickable ? ( e ) => {
e . currentTarget . style . borderColor = 'rgba(245,158,11,0.1)' ;
e . currentTarget . style . background = 'rgba(245,158,11,0.04)' ;
} : undefined }
>
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { {
fontFamily : 'monospace' , fontSize : '0.72rem' , fontWeight : '600' ,
color : '#CBD5E1' ,
overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' ,
} } title = { sub . workflow _name } >
{ sub . workflow _name || ` Batch ${ sub . ivanti _workflow _batch _id } ` }
< / d i v >
< div style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' , marginTop : '2px' } } >
< span style = { { fontFamily : 'monospace' , fontSize : '0.62rem' , color : '#64748B' } } >
# { sub . ivanti _workflow _batch _id }
< / s p a n >
< span style = { { fontFamily : 'monospace' , fontSize : '0.62rem' , color : '#475569' } } >
{ findingCount } finding { findingCount !== 1 ? 's' : '' }
< / s p a n >
< span style = { { fontFamily : 'monospace' , fontSize : '0.62rem' , color : '#334155' } } >
{ sub . created _at ? new Date ( sub . created _at ) . toLocaleDateString ( ) : '' }
< / s p a n >
< / d i v >
< / d i v >
< span style = { {
flexShrink : 0 ,
padding : '0.1rem 0.35rem' ,
borderRadius : '0.2rem' ,
background : lsBadge . bg ,
border : ` 1px solid ${ lsBadge . border } ` ,
color : lsBadge . text ,
fontFamily : 'monospace' , fontSize : '0.6rem' , fontWeight : '700' ,
textTransform : 'uppercase' , letterSpacing : '0.04em' ,
} } >
{ sub . lifecycle _status || 'submitted' }
< / s p a n >
< / d i v >
) ;
} ) }
< / d i v >
) }
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
{ /* Footer */ }
< div style = { {
padding : '0.75rem 1.25rem' ,
borderTop : '1px solid rgba(255,255,255,0.06)' ,
flexShrink : 0 ,
2026-03-26 15:43:43 -06:00
display : 'flex' , gap : '0.5rem' ,
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
} } >
2026-04-07 16:20:24 -06:00
{ /* Create FP Workflow — visible for editor/admin only */ }
2026-04-08 09:38:39 -06:00
{ canWrite && ( ( ) => {
const fpEnabled = isCreateFpButtonEnabled ( items , selectedIds ) ;
return (
< button
onClick = { ( ) => onCreateFpWorkflow ( [ ... selectedIds ] ) }
disabled = { ! fpEnabled }
title = { ! fpEnabled ? 'Select pending FP items to create a workflow' : '' }
style = { {
flex : 1 , padding : '0.45rem' ,
background : fpEnabled ? 'rgba(245,158,11,0.12)' : 'transparent' ,
border : ` 1px solid ${ fpEnabled ? 'rgba(245,158,11,0.35)' : 'rgba(255,255,255,0.05)' } ` ,
borderRadius : '0.375rem' ,
color : fpEnabled ? '#F59E0B' : '#334155' ,
fontFamily : 'monospace' , fontSize : '0.72rem' , fontWeight : '600' ,
cursor : fpEnabled ? 'pointer' : 'not-allowed' ,
textTransform : 'uppercase' , letterSpacing : '0.05em' ,
transition : 'all 0.12s' ,
} }
>
Create FP Workflow
< / b u t t o n >
) ;
} ) ( ) }
2026-03-26 15:43:43 -06:00
{ /* 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 } )
< / b u t t o n >
) }
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
< button
onClick = { onClearCompleted }
disabled = { completedCount === 0 }
style = { {
2026-03-26 15:43:43 -06:00
flex : 1 , padding : '0.45rem' ,
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
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 } ) ` : '' }
< / b u t t o n >
< / d i v >
< / d i v >
2026-04-09 16:01:36 -06:00
{ /* Redirect success notification */ }
{ redirectSuccess && (
< div style = { {
position : 'fixed' , top : '1rem' , right : '440px' ,
zIndex : 10001 ,
display : 'flex' , alignItems : 'center' , gap : '0.5rem' ,
padding : '0.5rem 1rem' ,
background : 'rgba(16, 185, 129, 0.15)' ,
border : '1px solid rgba(16, 185, 129, 0.4)' ,
borderRadius : '0.375rem' ,
boxShadow : '0 4px 12px rgba(0, 0, 0, 0.4)' ,
fontFamily : "'JetBrains Mono', monospace" ,
fontSize : '0.75rem' , fontWeight : '600' ,
color : '#10B981' ,
} } >
< Check style = { { width : '14px' , height : '14px' } } / >
{ redirectSuccess }
< / d i v >
) }
{ /* Redirect modal */ }
{ redirectItem && (
< RedirectModal
item = { redirectItem }
onClose = { ( ) => setRedirectItem ( null ) }
onRedirect = { handleRedirectSuccess }
/ >
) }
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
< / >
) ;
}
2026-04-07 16:20:24 -06:00
// ---------------------------------------------------------------------------
// FP Workflow helpers (pure functions, exported for testing)
// ---------------------------------------------------------------------------
function isCreateFpButtonEnabled ( items , selectedIds ) {
return items . some ( item =>
selectedIds . has ( item . id ) &&
item . workflow _type === 'FP' &&
item . status === 'pending'
) ;
}
function filterFpItems ( items ) {
return items . filter ( item => item . workflow _type === 'FP' ) ;
}
2026-04-08 09:38:39 -06:00
2026-04-07 16:20:24 -06:00
// ---------------------------------------------------------------------------
// FpWorkflowModal — submit FP workflows to Ivanti API
// ---------------------------------------------------------------------------
const ALLOWED _EXTENSIONS = [ '.pdf' , '.png' , '.jpg' , '.jpeg' , '.gif' , '.doc' , '.docx' , '.xlsx' , '.csv' , '.txt' , '.zip' ] ;
const MAX _FILE _SIZE = 10 * 1024 * 1024 ; // 10 MB
2026-04-15 15:27:21 -06:00
// ---------------------------------------------------------------------------
// AttachmentSourcePicker — shared component for local + library attachments
// ---------------------------------------------------------------------------
function AttachmentSourcePicker ( { files , onFilesChange , libraryDocs , onLibraryDocsChange , disabled } ) {
const [ mode , setMode ] = useState ( 'local' ) ;
const [ searchQuery , setSearchQuery ] = useState ( '' ) ;
const [ searchResults , setSearchResults ] = useState ( [ ] ) ;
const [ searching , setSearching ] = useState ( false ) ;
const [ searchError , setSearchError ] = useState ( null ) ;
const [ fileErrors , setFileErrors ] = useState ( null ) ;
const fileInputRef = useRef ( null ) ;
const dropRef = useRef ( null ) ;
const debounceRef = useRef ( null ) ;
// Format file size helper
const formatSize = ( bytes ) => {
const n = Number ( bytes ) ;
if ( isNaN ( n ) || n < 0 ) return '0 B' ;
if ( n < 1024 ) return n + ' B' ;
if ( n < 1024 * 1024 ) return ( n / 1024 ) . toFixed ( 1 ) + ' KB' ;
return ( n / ( 1024 * 1024 ) ) . toFixed ( 1 ) + ' MB' ;
} ;
// File validation
const isAllowedExtension = ( filename ) => {
const ext = '.' + filename . split ( '.' ) . pop ( ) . toLowerCase ( ) ;
return ALLOWED _EXTENSIONS . includes ( ext ) ;
} ;
const addFiles = ( newFiles ) => {
if ( disabled ) return ;
const errors = [ ] ;
const valid = [ ] ;
Array . from ( newFiles ) . forEach ( f => {
if ( ! isAllowedExtension ( f . name ) ) {
errors . push ( ` " ${ f . name } " — file type not allowed. Accepted: ${ ALLOWED _EXTENSIONS . join ( ', ' ) } ` ) ;
} else if ( f . size > MAX _FILE _SIZE ) {
errors . push ( ` " ${ f . name } " — exceeds 10 MB limit ` ) ;
} else {
valid . push ( f ) ;
}
} ) ;
if ( errors . length ) {
setFileErrors ( errors . join ( '; ' ) ) ;
} else {
setFileErrors ( null ) ;
}
if ( valid . length ) onFilesChange ( [ ... files , ... valid ] ) ;
} ;
const removeFile = ( idx ) => {
if ( disabled ) return ;
onFilesChange ( files . filter ( ( _ , i ) => i !== idx ) ) ;
setFileErrors ( null ) ;
} ;
const removeLibraryDoc = ( docId ) => {
if ( disabled ) return ;
onLibraryDocsChange ( libraryDocs . filter ( d => d . id !== docId ) ) ;
} ;
const handleDrop = ( e ) => {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
if ( disabled ) return ;
if ( e . dataTransfer . files ? . length ) addFiles ( e . dataTransfer . files ) ;
} ;
const handleDragOver = ( e ) => { e . preventDefault ( ) ; e . stopPropagation ( ) ; } ;
// Library search with debounce
useEffect ( ( ) => {
if ( mode !== 'library' ) return ;
if ( debounceRef . current ) clearTimeout ( debounceRef . current ) ;
debounceRef . current = setTimeout ( async ( ) => {
setSearching ( true ) ;
setSearchError ( null ) ;
try {
const url = searchQuery . trim ( )
? ` ${ API _BASE } /ivanti/fp-workflow/documents/search?q= ${ encodeURIComponent ( searchQuery . trim ( ) ) } `
: ` ${ API _BASE } /ivanti/fp-workflow/documents/search ` ;
const res = await fetch ( url , { credentials : 'include' } ) ;
if ( ! res . ok ) throw new Error ( ` Search failed ( ${ res . status } ) ` ) ;
const data = await res . json ( ) ;
setSearchResults ( data ) ;
} catch ( err ) {
setSearchError ( err . message || 'Failed to search documents' ) ;
setSearchResults ( [ ] ) ;
} finally {
setSearching ( false ) ;
}
} , 300 ) ;
return ( ) => { if ( debounceRef . current ) clearTimeout ( debounceRef . current ) ; } ;
} , [ searchQuery , mode ] ) ;
const selectLibraryDoc = ( doc ) => {
if ( disabled ) return ;
if ( libraryDocs . some ( d => d . id === doc . id ) ) return ;
onLibraryDocsChange ( [ ... libraryDocs , {
id : doc . id ,
cve _id : doc . cve _id ,
vendor : doc . vendor ,
name : doc . name ,
file _size : doc . file _size ,
mime _type : doc . mime _type ,
} ] ) ;
} ;
const selectedIds = new Set ( libraryDocs . map ( d => d . id ) ) ;
// ---- Styles ----
const tabBtnStyle = ( active ) => ( {
flex : 1 ,
padding : '0.45rem 0.5rem' ,
background : 'none' ,
border : 'none' ,
borderBottom : active ? '2px solid #0EA5E9' : '2px solid transparent' ,
color : active ? '#0EA5E9' : '#475569' ,
fontFamily : 'monospace' ,
fontSize : '0.72rem' ,
fontWeight : '600' ,
cursor : disabled ? 'not-allowed' : 'pointer' ,
textTransform : 'uppercase' ,
letterSpacing : '0.05em' ,
transition : 'all 0.12s' ,
} ) ;
const dropZoneStyle = {
border : '1px dashed rgba(14,165,233,0.25)' ,
borderRadius : '0.375rem' ,
padding : '1rem' ,
textAlign : 'center' ,
cursor : disabled ? 'not-allowed' : 'pointer' ,
background : 'rgba(14,165,233,0.03)' ,
transition : 'border-color 0.15s' ,
} ;
const searchInputStyle = {
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.45rem 0.6rem 0.45rem 2rem' ,
color : '#CBD5E1' ,
fontSize : '0.78rem' ,
fontFamily : 'monospace' ,
outline : 'none' ,
} ;
const resultItemStyle = ( isSelected ) => ( {
display : 'flex' ,
alignItems : 'center' ,
gap : '0.5rem' ,
padding : '0.4rem 0.5rem' ,
borderBottom : '1px solid rgba(255,255,255,0.03)' ,
cursor : disabled || isSelected ? 'default' : 'pointer' ,
opacity : isSelected ? 0.5 : 1 ,
background : isSelected ? 'rgba(14,165,233,0.04)' : 'transparent' ,
transition : 'background 0.1s' ,
} ) ;
const badgeStyle = ( type ) => ( {
display : 'inline-block' ,
padding : '0.1rem 0.3rem' ,
borderRadius : '0.15rem' ,
fontFamily : 'monospace' ,
fontSize : '0.58rem' ,
fontWeight : '700' ,
textTransform : 'uppercase' ,
letterSpacing : '0.04em' ,
... ( type === 'local'
? { background : 'rgba(14,165,233,0.15)' , color : '#0EA5E9' , border : '1px solid rgba(14,165,233,0.3)' }
: { background : 'rgba(245,158,11,0.15)' , color : '#F59E0B' , border : '1px solid rgba(245,158,11,0.3)' }
) ,
} ) ;
const totalAttachments = files . length + libraryDocs . length ;
return (
< div >
{ /* Mode toggle tabs */ }
< div style = { { display : 'flex' , borderBottom : '1px solid rgba(255,255,255,0.06)' , marginBottom : '0.625rem' } } >
< button
style = { tabBtnStyle ( mode === 'local' ) }
onClick = { ( ) => ! disabled && setMode ( 'local' ) }
disabled = { disabled }
>
Local Upload
< / b u t t o n >
< button
style = { tabBtnStyle ( mode === 'library' ) }
onClick = { ( ) => ! disabled && setMode ( 'library' ) }
disabled = { disabled }
>
Library
< / b u t t o n >
< / d i v >
{ /* Local Upload mode */ }
{ mode === 'local' && (
< div >
< div
ref = { dropRef }
onDrop = { handleDrop }
onDragOver = { handleDragOver }
onClick = { ( ) => ! disabled && fileInputRef . current ? . click ( ) }
style = { dropZoneStyle }
>
< Upload size = { 20 } style = { { color : '#475569' , marginBottom : '0.35rem' } } / >
< div style = { { fontSize : '0.75rem' , color : '#64748B' } } >
Drop files here or click to browse
< / d i v >
< div style = { { fontSize : '0.62rem' , color : '#475569' , marginTop : '0.2rem' } } >
Max 10 MB per file · PDF , PNG , JPG , DOC , XLSX , CSV , TXT , ZIP
< / d i v >
< / d i v >
< input
ref = { fileInputRef }
type = "file"
multiple
style = { { display : 'none' } }
onChange = { e => { if ( e . target . files ? . length ) addFiles ( e . target . files ) ; e . target . value = '' ; } }
accept = { ALLOWED _EXTENSIONS . join ( ',' ) }
disabled = { disabled }
/ >
{ fileErrors && (
< div style = { { fontSize : '0.68rem' , color : '#EF4444' , marginTop : '0.3rem' } } > { fileErrors } < / d i v >
) }
< / d i v >
) }
{ /* Library mode */ }
{ mode === 'library' && (
< div >
{ /* Search input */ }
< div style = { { position : 'relative' , marginBottom : '0.5rem' } } >
< Search size = { 14 } style = { { position : 'absolute' , left : '0.5rem' , top : '50%' , transform : 'translateY(-50%)' , color : '#475569' } } / >
< input
type = "text"
value = { searchQuery }
onChange = { e => setSearchQuery ( e . target . value ) }
placeholder = "Search documents by name, CVE, or vendor..."
disabled = { disabled }
style = { searchInputStyle }
/ >
{ searching && (
< Loader size = { 14 } style = { { position : 'absolute' , right : '0.5rem' , top : '50%' , transform : 'translateY(-50%)' , color : '#0EA5E9' , animation : 'spin 1s linear infinite' } } / >
) }
< / d i v >
{ /* Search results */ }
< div style = { {
maxHeight : '200px' ,
overflowY : 'auto' ,
border : '1px solid rgba(14,165,233,0.1)' ,
borderRadius : '0.25rem' ,
background : 'rgba(15,23,42,0.5)' ,
} } >
{ searchError && (
< div style = { { padding : '0.75rem' , textAlign : 'center' , fontSize : '0.72rem' , color : '#EF4444' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' , gap : '0.375rem' } } >
< AlertCircle size = { 13 } / >
{ searchError }
< / d i v >
) }
{ ! searchError && ! searching && searchResults . length === 0 && (
< div style = { { padding : '0.75rem' , textAlign : 'center' , fontSize : '0.72rem' , color : '#475569' } } >
No documents found
< / d i v >
) }
{ ! searchError && searching && searchResults . length === 0 && (
< div style = { { padding : '0.75rem' , textAlign : 'center' , fontSize : '0.72rem' , color : '#64748B' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' , gap : '0.375rem' } } >
< Loader size = { 13 } style = { { animation : 'spin 1s linear infinite' } } / >
Searching ...
< / d i v >
) }
{ ! searchError && searchResults . map ( doc => {
const isSelected = selectedIds . has ( doc . id ) ;
return (
< div
key = { doc . id }
style = { resultItemStyle ( isSelected ) }
onClick = { ( ) => ! isSelected && selectLibraryDoc ( doc ) }
>
{ isSelected ? (
< Check size = { 13 } style = { { color : '#10B981' , flexShrink : 0 } } / >
) : (
< Database size = { 13 } style = { { color : '#F59E0B' , flexShrink : 0 } } / >
) }
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { { fontSize : '0.72rem' , color : '#CBD5E1' , overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' , fontFamily : 'monospace' } } >
{ doc . name }
< / d i v >
< div style = { { fontSize : '0.62rem' , color : '#64748B' , fontFamily : 'monospace' , display : 'flex' , gap : '0.5rem' , marginTop : '0.1rem' } } >
{ doc . cve _id && < span style = { { color : '#0EA5E9' } } > { doc . cve _id } < / s p a n > }
{ doc . vendor && < span > { doc . vendor } < / s p a n > }
< span > { formatSize ( doc . file _size ) } < / s p a n >
< / d i v >
< / d i v >
< / d i v >
) ;
} ) }
< / d i v >
< / d i v >
) }
{ /* Unified attachment list */ }
{ totalAttachments > 0 && (
< div style = { { marginTop : '0.625rem' } } >
< div style = { {
fontSize : '0.68rem' ,
fontWeight : '600' ,
color : '#64748B' ,
textTransform : 'uppercase' ,
letterSpacing : '0.08em' ,
marginBottom : '0.35rem' ,
fontFamily : 'monospace' ,
} } >
Attachments ( { totalAttachments } )
< / d i v >
{ /* Local files */ }
{ files . map ( ( f , i ) => (
< div key = { ` local- ${ i } ` } style = { {
display : 'flex' ,
alignItems : 'center' ,
gap : '0.5rem' ,
padding : '0.3rem 0.25rem' ,
borderBottom : '1px solid rgba(255,255,255,0.03)' ,
} } >
< span style = { badgeStyle ( 'local' ) } > LOCAL < / s p a n >
< FileText size = { 13 } style = { { color : '#64748B' , flexShrink : 0 } } / >
< span style = { {
flex : 1 ,
fontSize : '0.72rem' ,
color : '#CBD5E1' ,
fontFamily : 'monospace' ,
overflow : 'hidden' ,
textOverflow : 'ellipsis' ,
whiteSpace : 'nowrap' ,
} } >
{ f . name }
< / s p a n >
< span style = { { fontSize : '0.62rem' , color : '#475569' , fontFamily : 'monospace' , flexShrink : 0 } } >
{ formatSize ( f . size ) }
< / s p a n >
< button
onClick = { ( ) => removeFile ( i ) }
disabled = { disabled }
style = { {
background : 'none' ,
border : 'none' ,
color : '#64748B' ,
cursor : disabled ? 'not-allowed' : 'pointer' ,
padding : '0.15rem' ,
lineHeight : 1 ,
} }
>
< Trash2 size = { 12 } / >
< / b u t t o n >
< / d i v >
) ) }
{ /* Library docs */ }
{ libraryDocs . map ( doc => (
< div key = { ` lib- ${ doc . id } ` } style = { {
display : 'flex' ,
alignItems : 'center' ,
gap : '0.5rem' ,
padding : '0.3rem 0.25rem' ,
borderBottom : '1px solid rgba(255,255,255,0.03)' ,
} } >
< span style = { badgeStyle ( 'library' ) } > LIBRARY < / s p a n >
< Database size = { 13 } style = { { color : '#F59E0B' , flexShrink : 0 } } / >
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { {
fontSize : '0.72rem' ,
color : '#CBD5E1' ,
fontFamily : 'monospace' ,
overflow : 'hidden' ,
textOverflow : 'ellipsis' ,
whiteSpace : 'nowrap' ,
} } >
{ doc . name }
< / d i v >
< div style = { { fontSize : '0.6rem' , color : '#64748B' , fontFamily : 'monospace' , display : 'flex' , gap : '0.5rem' } } >
{ doc . cve _id && < span style = { { color : '#0EA5E9' } } > { doc . cve _id } < / s p a n > }
{ doc . vendor && < span > { doc . vendor } < / s p a n > }
< / d i v >
< / d i v >
< span style = { { fontSize : '0.62rem' , color : '#475569' , fontFamily : 'monospace' , flexShrink : 0 } } >
{ formatSize ( doc . file _size ) }
< / s p a n >
< button
onClick = { ( ) => removeLibraryDoc ( doc . id ) }
disabled = { disabled }
style = { {
background : 'none' ,
border : 'none' ,
color : '#64748B' ,
cursor : disabled ? 'not-allowed' : 'pointer' ,
padding : '0.15rem' ,
lineHeight : 1 ,
} }
>
< Trash2 size = { 12 } / >
< / b u t t o n >
< / d i v >
) ) }
< / d i v >
) }
< / d i v >
) ;
}
2026-04-07 16:20:24 -06:00
function FpWorkflowModal ( { open , onClose , selectedItems , onSuccess } ) {
const [ name , setName ] = useState ( '' ) ;
const [ reason , setReason ] = useState ( '' ) ;
const [ description , setDescription ] = useState ( '' ) ;
const [ expirationDate , setExpirationDate ] = useState ( '' ) ;
const [ scopeOverride , setScopeOverride ] = useState ( 'Authorized' ) ;
const [ files , setFiles ] = useState ( [ ] ) ;
2026-04-15 15:27:21 -06:00
const [ libraryDocs , setLibraryDocs ] = useState ( [ ] ) ;
2026-04-07 16:20:24 -06:00
const [ submitting , setSubmitting ] = useState ( false ) ;
const [ progress , setProgress ] = useState ( { step : '' , current : 0 , total : 0 } ) ;
const [ errors , setErrors ] = useState ( { } ) ;
const [ result , setResult ] = useState ( null ) ;
// Reset form when modal opens
useEffect ( ( ) => {
if ( open ) {
setName ( '' ) ;
setReason ( '' ) ;
setDescription ( '' ) ;
setExpirationDate ( '' ) ;
setScopeOverride ( 'Authorized' ) ;
setFiles ( [ ] ) ;
2026-04-15 15:27:21 -06:00
setLibraryDocs ( [ ] ) ;
2026-04-07 16:20:24 -06:00
setSubmitting ( false ) ;
setProgress ( { step : '' , current : 0 , total : 0 } ) ;
setErrors ( { } ) ;
setResult ( null ) ;
}
} , [ open ] ) ;
// Close on Escape
useEffect ( ( ) => {
if ( ! open ) return ;
const handler = ( e ) => { if ( e . key === 'Escape' && ! submitting ) onClose ( ) ; } ;
document . addEventListener ( 'keydown' , handler ) ;
return ( ) => document . removeEventListener ( 'keydown' , handler ) ;
} , [ open , submitting , onClose ] ) ;
const validate = ( ) => {
const errs = { } ;
if ( ! name . trim ( ) ) errs . name = 'Workflow name is required' ;
else if ( name . trim ( ) . length > 255 ) errs . name = 'Name must be 255 characters or fewer' ;
if ( ! reason . trim ( ) ) errs . reason = 'Reason is required' ;
if ( description . length > 2000 ) errs . description = 'Description must be 2000 characters or fewer' ;
if ( ! expirationDate ) errs . expirationDate = 'Expiration date is required' ;
else {
const today = new Date ( ) ;
today . setHours ( 0 , 0 , 0 , 0 ) ;
const exp = new Date ( expirationDate + 'T00:00:00' ) ;
if ( exp <= today ) errs . expirationDate = 'Expiration date must be in the future' ;
2026-04-22 19:52:06 +00:00
else {
const maxDate = new Date ( today ) ;
maxDate . setDate ( maxDate . getDate ( ) + 120 ) ;
if ( exp > maxDate ) errs . expirationDate = 'Expiration date cannot be more than 120 days from today' ;
}
2026-04-07 16:20:24 -06:00
}
setErrors ( errs ) ;
return Object . keys ( errs ) . length === 0 ;
} ;
const handleSubmit = async ( ) => {
if ( ! validate ( ) ) return ;
setSubmitting ( true ) ;
setProgress ( { step : 'Creating workflow...' , current : 0 , total : 0 } ) ;
setResult ( null ) ;
try {
const formData = new FormData ( ) ;
formData . append ( 'name' , name . trim ( ) ) ;
formData . append ( 'reason' , reason . trim ( ) ) ;
if ( description . trim ( ) ) formData . append ( 'description' , description . trim ( ) ) ;
formData . append ( 'expirationDate' , expirationDate ) ;
formData . append ( 'scopeOverride' , scopeOverride ) ;
formData . append ( 'findingIds' , JSON . stringify ( selectedItems . map ( i => i . finding _id ) ) ) ;
formData . append ( 'queueItemIds' , JSON . stringify ( selectedItems . map ( i => i . id ) ) ) ;
files . forEach ( f => formData . append ( 'attachments' , f ) ) ;
2026-04-15 15:27:21 -06:00
if ( libraryDocs . length > 0 ) {
formData . append ( 'libraryDocIds' , JSON . stringify ( libraryDocs . map ( d => d . id ) ) ) ;
}
2026-04-07 16:20:24 -06:00
2026-04-15 15:27:21 -06:00
const totalAttachments = files . length + libraryDocs . length ;
if ( totalAttachments > 0 ) {
setProgress ( { step : 'Creating workflow and uploading attachments...' , current : 0 , total : totalAttachments } ) ;
2026-04-07 16:20:24 -06:00
}
const res = await fetch ( ` ${ API _BASE } /ivanti/fp-workflow ` , {
method : 'POST' ,
credentials : 'include' ,
body : formData ,
} ) ;
const data = await res . json ( ) ;
if ( res . ok && data . success ) {
setResult ( {
success : true ,
workflowBatchId : data . workflowBatchId ,
generatedId : data . generatedId ,
attachmentResults : data . attachmentResults || [ ] ,
status : data . status || 'success' ,
} ) ;
onSuccess ( ) ;
} else {
let errorMsg = data . error || 'Workflow creation failed' ;
if ( res . status === 401 ) errorMsg = 'Ivanti API key is invalid or missing. Contact your administrator.' ;
else if ( res . status === 429 ) errorMsg = 'Ivanti API rate limit reached. Please try again in a few minutes.' ;
setResult ( {
success : false ,
error : errorMsg ,
workflowBatchId : data . workflowBatchId || null ,
generatedId : data . generatedId || null ,
attachmentResults : data . attachmentResults || [ ] ,
status : data . status || 'failed' ,
} ) ;
}
} catch ( err ) {
setResult ( {
success : false ,
error : err . message || 'Network error — could not reach the server' ,
status : 'failed' ,
} ) ;
} finally {
setSubmitting ( false ) ;
}
} ;
if ( ! open ) return null ;
// ---- Styles ----
const overlayStyle = {
position : 'fixed' , inset : 0 , zIndex : 10000 ,
background : 'rgba(0,0,0,0.6)' ,
display : 'flex' , alignItems : 'center' , justifyContent : 'center' ,
} ;
const modalStyle = {
width : '640px' , maxHeight : '90vh' , overflow : 'auto' ,
background : 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)' ,
border : '1px solid rgba(245,158,11,0.3)' ,
borderRadius : '0.75rem' ,
boxShadow : '0 12px 48px rgba(0,0,0,0.8)' ,
fontFamily : 'monospace' ,
} ;
const headerStyle = {
display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' ,
padding : '1rem 1.25rem' ,
borderBottom : '1px solid rgba(245,158,11,0.2)' ,
} ;
const sectionStyle = {
padding : '0.875rem 1.25rem' ,
borderBottom : '1px solid rgba(255,255,255,0.04)' ,
} ;
const labelStyle = {
display : 'block' , fontSize : '0.68rem' , fontWeight : '600' ,
color : '#64748B' , textTransform : 'uppercase' , letterSpacing : '0.08em' ,
marginBottom : '0.35rem' ,
} ;
const inputStyle = {
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.45rem 0.6rem' ,
color : '#CBD5E1' , fontSize : '0.82rem' , fontFamily : 'monospace' ,
outline : 'none' ,
} ;
const inputErrorStyle = { ... inputStyle , borderColor : '#EF4444' } ;
const textareaStyle = { ... inputStyle , minHeight : '60px' , resize : 'vertical' } ;
const textareaErrorStyle = { ... textareaStyle , borderColor : '#EF4444' } ;
const errorTextStyle = { fontSize : '0.68rem' , color : '#EF4444' , marginTop : '0.2rem' } ;
const footerStyle = {
display : 'flex' , alignItems : 'center' , justifyContent : 'flex-end' , gap : '0.625rem' ,
padding : '0.875rem 1.25rem' ,
} ;
// ---- Result views ----
if ( result ) {
return ReactDOM . createPortal (
< div style = { overlayStyle } onClick = { ( ) => { if ( ! submitting ) onClose ( ) ; } } >
< div style = { modalStyle } onClick = { e => e . stopPropagation ( ) } >
< div style = { headerStyle } >
< span style = { { fontSize : '0.88rem' , fontWeight : '700' , color : result . success ? '#10B981' : '#EF4444' } } >
{ result . success ? 'Workflow Created' : 'Submission Failed' }
< / s p a n >
< button onClick = { onClose } style = { { background : 'none' , border : 'none' , color : '#64748B' , cursor : 'pointer' } } >
< X size = { 16 } / >
< / b u t t o n >
< / d i v >
< div style = { { padding : '1.5rem 1.25rem' , textAlign : 'center' } } >
{ result . success ? (
< >
< div style = { { marginBottom : '1rem' } } >
< Check size = { 36 } style = { { color : '#10B981' } } / >
< / d i v >
< div style = { { fontSize : '1.1rem' , fontWeight : '700' , color : '#F59E0B' , marginBottom : '0.5rem' } } >
{ result . generatedId || ` Batch # ${ result . workflowBatchId } ` }
< / d i v >
< div style = { { fontSize : '0.78rem' , color : '#94A3B8' , marginBottom : '1rem' } } >
FP workflow created successfully with { selectedItems . length } finding { selectedItems . length !== 1 ? 's' : '' } .
< / d i v >
{ result . attachmentResults . length > 0 && (
< div style = { { textAlign : 'left' , marginBottom : '1rem' } } >
< div style = { labelStyle } > Attachments < / d i v >
{ result . attachmentResults . map ( ( a , i ) => (
< div key = { i } style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' , fontSize : '0.75rem' , color : a . success ? '#10B981' : '#EF4444' , marginBottom : '0.25rem' } } >
{ a . success ? < Check size = { 12 } / > : < AlertTriangle size = { 12 } / > }
2026-04-15 15:27:21 -06:00
< span style = { {
display : 'inline-block' ,
fontSize : '0.6rem' ,
fontWeight : '600' ,
padding : '0.1rem 0.3rem' ,
borderRadius : '0.2rem' ,
background : ( a . source || 'local' ) === 'library' ? 'rgba(168,85,247,0.12)' : 'rgba(14,165,233,0.12)' ,
color : ( a . source || 'local' ) === 'library' ? '#A855F7' : '#0EA5E9' ,
border : ` 1px solid ${ ( a . source || 'local' ) === 'library' ? 'rgba(168,85,247,0.3)' : 'rgba(14,165,233,0.3)' } ` ,
textTransform : 'uppercase' ,
letterSpacing : '0.04em' ,
flexShrink : 0 ,
} } > { ( a . source || 'local' ) === 'library' ? 'Library' : 'Local' } < / s p a n >
2026-04-07 16:20:24 -06:00
< span > { a . filename } < / s p a n >
< / d i v >
) ) }
< / d i v >
) }
< / >
) : (
< >
< div style = { { marginBottom : '1rem' } } >
< AlertTriangle size = { 36 } style = { { color : '#EF4444' } } / >
< / d i v >
< div style = { { fontSize : '0.88rem' , fontWeight : '600' , color : '#E2E8F0' , marginBottom : '0.5rem' } } >
{ result . error }
< / d i v >
{ result . generatedId && (
< div style = { { fontSize : '0.78rem' , color : '#F59E0B' , marginBottom : '0.5rem' } } >
Workflow was created : { result . generatedId }
< / d i v >
) }
{ result . attachmentResults ? . length > 0 && (
< div style = { { textAlign : 'left' , marginBottom : '1rem' } } >
< div style = { labelStyle } > Attachment Results < / d i v >
{ result . attachmentResults . map ( ( a , i ) => (
< div key = { i } style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' , fontSize : '0.75rem' , color : a . success ? '#10B981' : '#EF4444' , marginBottom : '0.25rem' } } >
{ a . success ? < Check size = { 12 } / > : < AlertTriangle size = { 12 } / > }
2026-04-15 15:27:21 -06:00
< span style = { {
display : 'inline-block' ,
fontSize : '0.6rem' ,
fontWeight : '600' ,
padding : '0.1rem 0.3rem' ,
borderRadius : '0.2rem' ,
background : ( a . source || 'local' ) === 'library' ? 'rgba(168,85,247,0.12)' : 'rgba(14,165,233,0.12)' ,
color : ( a . source || 'local' ) === 'library' ? '#A855F7' : '#0EA5E9' ,
border : ` 1px solid ${ ( a . source || 'local' ) === 'library' ? 'rgba(168,85,247,0.3)' : 'rgba(14,165,233,0.3)' } ` ,
textTransform : 'uppercase' ,
letterSpacing : '0.04em' ,
flexShrink : 0 ,
} } > { ( a . source || 'local' ) === 'library' ? 'Library' : 'Local' } < / s p a n >
2026-04-07 16:20:24 -06:00
< span > { a . filename } < / s p a n >
< / d i v >
) ) }
< / d i v >
) }
< / >
) }
< / d i v >
< div style = { footerStyle } >
{ ! result . success && (
< button
onClick = { ( ) => setResult ( null ) }
style = { {
padding : '0.45rem 1rem' ,
background : 'rgba(245,158,11,0.1)' ,
border : '1px solid rgba(245,158,11,0.3)' ,
borderRadius : '0.375rem' ,
color : '#F59E0B' , fontSize : '0.78rem' , fontWeight : '600' ,
cursor : 'pointer' , fontFamily : 'monospace' ,
} }
>
Retry
< / b u t t o n >
) }
< button
onClick = { onClose }
style = { {
padding : '0.45rem 1rem' ,
background : result . success ? 'rgba(16,185,129,0.12)' : 'rgba(255,255,255,0.04)' ,
border : ` 1px solid ${ result . success ? 'rgba(16,185,129,0.3)' : 'rgba(255,255,255,0.1)' } ` ,
borderRadius : '0.375rem' ,
color : result . success ? '#10B981' : '#94A3B8' ,
fontSize : '0.78rem' , fontWeight : '600' ,
cursor : 'pointer' , fontFamily : 'monospace' ,
} }
>
Done
< / b u t t o n >
< / d i v >
< / d i v >
< / d i v > ,
document . body
) ;
}
// ---- Form view ----
return ReactDOM . createPortal (
< div style = { overlayStyle } onClick = { ( ) => { if ( ! submitting ) onClose ( ) ; } } >
< div style = { modalStyle } onClick = { e => e . stopPropagation ( ) } >
{ /* Header */ }
< div style = { headerStyle } >
< span style = { { fontSize : '0.88rem' , fontWeight : '700' , color : '#F59E0B' } } >
Create FP Workflow
< / s p a n >
< button onClick = { ( ) => { if ( ! submitting ) onClose ( ) ; } } style = { { background : 'none' , border : 'none' , color : '#64748B' , cursor : 'pointer' } } >
< X size = { 16 } / >
< / b u t t o n >
< / d i v >
{ /* Selected findings summary */ }
< div style = { sectionStyle } >
< div style = { labelStyle } > Selected Findings ( { selectedItems . length } ) < / d i v >
< div style = { { maxHeight : '120px' , overflow : 'auto' } } >
{ selectedItems . map ( ( item , i ) => (
< div key = { item . id || i } style = { { display : 'flex' , gap : '0.5rem' , alignItems : 'baseline' , fontSize : '0.75rem' , color : '#94A3B8' , marginBottom : '0.3rem' } } >
< span style = { { color : '#F59E0B' , fontWeight : '600' , flexShrink : 0 } } > { item . finding _id } < / s p a n >
< span style = { { color : '#CBD5E1' , overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' , flex : 1 } } > { item . finding _title || '—' } < / s p a n >
{ item . cves _json && ( ( ) => {
try {
const cves = JSON . parse ( item . cves _json ) ;
return cves . length > 0 ? < span style = { { color : '#64748B' , flexShrink : 0 } } > { cves . join ( ', ' ) } < / s p a n > : n u l l ;
} catch { return null ; }
} ) ( ) }
< / d i v >
) ) }
< / d i v >
< / d i v >
{ /* Form fields */ }
< div style = { sectionStyle } >
{ /* Name */ }
< label style = { { display : 'block' , marginBottom : '0.75rem' } } >
< span style = { labelStyle } > Workflow Name < span style = { { color : '#EF4444' } } > * < / s p a n > < / s p a n >
< input
type = "text"
value = { name }
onChange = { e => setName ( e . target . value ) }
placeholder = "FP — CVE-2024-XXXX — Vendor"
disabled = { submitting }
maxLength = { 255 }
style = { errors . name ? inputErrorStyle : inputStyle }
/ >
{ errors . name && < div style = { errorTextStyle } > { errors . name } < / d i v > }
< / l a b e l >
{ /* Reason */ }
< label style = { { display : 'block' , marginBottom : '0.75rem' } } >
< span style = { labelStyle } > Reason / Justification < span style = { { color : '#EF4444' } } > * < / s p a n > < / s p a n >
< textarea
value = { reason }
onChange = { e => setReason ( e . target . value ) }
placeholder = "Explain why these findings are false positives..."
disabled = { submitting }
style = { errors . reason ? textareaErrorStyle : textareaStyle }
/ >
{ errors . reason && < div style = { errorTextStyle } > { errors . reason } < / d i v > }
< / l a b e l >
{ /* Description */ }
< label style = { { display : 'block' , marginBottom : '0.75rem' } } >
< span style = { labelStyle } > Description ( optional ) < / s p a n >
< textarea
value = { description }
onChange = { e => setDescription ( e . target . value ) }
placeholder = "Additional context or details..."
disabled = { submitting }
maxLength = { 2000 }
style = { errors . description ? textareaErrorStyle : textareaStyle }
/ >
{ errors . description && < div style = { errorTextStyle } > { errors . description } < / d i v > }
< div style = { { fontSize : '0.62rem' , color : '#475569' , textAlign : 'right' , marginTop : '0.15rem' } } > { description . length } / 2000 < / d i v >
< / l a b e l >
{ /* Expiration date */ }
< label style = { { display : 'block' , marginBottom : '0.75rem' } } >
< span style = { labelStyle } > Expiration Date < span style = { { color : '#EF4444' } } > * < / s p a n > < / s p a n >
< input
type = "date"
value = { expirationDate }
onChange = { e => setExpirationDate ( e . target . value ) }
2026-04-22 19:52:06 +00:00
min = { ( ( ) => { const d = new Date ( ) ; d . setDate ( d . getDate ( ) + 1 ) ; return d . toISOString ( ) . split ( 'T' ) [ 0 ] ; } ) ( ) }
max = { ( ( ) => { const d = new Date ( ) ; d . setDate ( d . getDate ( ) + 120 ) ; return d . toISOString ( ) . split ( 'T' ) [ 0 ] ; } ) ( ) }
2026-04-07 16:20:24 -06:00
disabled = { submitting }
style = { errors . expirationDate ? inputErrorStyle : inputStyle }
/ >
{ errors . expirationDate && < div style = { errorTextStyle } > { errors . expirationDate } < / d i v > }
< / l a b e l >
{ /* Scope override toggle */ }
< div style = { { marginBottom : '0.25rem' } } >
< span style = { labelStyle } > Scope Override Authorization < / s p a n >
< div style = { { display : 'flex' , gap : '0.5rem' } } >
{ [ 'Authorized' , 'None' ] . map ( val => {
const active = scopeOverride === val ;
return (
< button
key = { val }
onClick = { ( ) => setScopeOverride ( val ) }
disabled = { submitting }
style = { {
flex : 1 , padding : '0.35rem' ,
background : active ? 'rgba(245,158,11,0.12)' : 'transparent' ,
border : ` 1px solid ${ active ? 'rgba(245,158,11,0.4)' : 'rgba(255,255,255,0.08)' } ` ,
borderRadius : '0.25rem' ,
color : active ? '#F59E0B' : '#475569' ,
fontFamily : 'monospace' , fontSize : '0.75rem' , fontWeight : '600' ,
cursor : submitting ? 'not-allowed' : 'pointer' ,
transition : 'all 0.12s' ,
} }
>
{ val }
< / b u t t o n >
) ;
} ) }
< / d i v >
< / d i v >
< / d i v >
2026-04-15 15:27:21 -06:00
{ /* Attachments */ }
2026-04-07 16:20:24 -06:00
< div style = { sectionStyle } >
< div style = { labelStyle } > Attachments < / d i v >
2026-04-15 15:27:21 -06:00
< AttachmentSourcePicker
files = { files }
onFilesChange = { setFiles }
libraryDocs = { libraryDocs }
onLibraryDocsChange = { setLibraryDocs }
disabled = { submitting }
2026-04-07 16:20:24 -06:00
/ >
< / d i v >
{ /* Footer */ }
< div style = { footerStyle } >
{ submitting && (
< div style = { { flex : 1 , display : 'flex' , alignItems : 'center' , gap : '0.5rem' , fontSize : '0.75rem' , color : '#F59E0B' } } >
< Loader size = { 14 } style = { { animation : 'spin 1s linear infinite' } } / >
< span > { progress . step } < / s p a n >
< / d i v >
) }
< button
onClick = { ( ) => { if ( ! submitting ) onClose ( ) ; } }
disabled = { submitting }
style = { {
padding : '0.45rem 1rem' ,
background : 'none' ,
border : '1px solid rgba(255,255,255,0.08)' ,
borderRadius : '0.375rem' ,
color : '#64748B' , fontSize : '0.78rem' , fontWeight : '600' ,
cursor : submitting ? 'not-allowed' : 'pointer' ,
fontFamily : 'monospace' ,
} }
>
Cancel
< / b u t t o n >
< button
onClick = { handleSubmit }
disabled = { submitting }
style = { {
padding : '0.45rem 1.25rem' ,
background : submitting ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)' ,
border : ` 1px solid ${ submitting ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)' } ` ,
borderRadius : '0.375rem' ,
color : submitting ? '#92700C' : '#F59E0B' ,
fontSize : '0.78rem' , fontWeight : '700' ,
cursor : submitting ? 'not-allowed' : 'pointer' ,
fontFamily : 'monospace' , textTransform : 'uppercase' , letterSpacing : '0.05em' ,
} }
>
{ submitting ? 'Submitting...' : 'Submit' }
< / b u t t o n >
< / d i v >
< / d i v >
< / d i v > ,
document . body
) ;
}
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
// ---------------------------------------------------------------------------
// FpEditModal — edit existing FP submissions (tabbed modal)
// ---------------------------------------------------------------------------
function FpEditModal ( { open , onClose , submission , queueItems , onSuccess } ) {
const [ activeTab , setActiveTab ] = useState ( 'details' ) ;
const [ name , setName ] = useState ( '' ) ;
const [ reason , setReason ] = useState ( '' ) ;
const [ description , setDescription ] = useState ( '' ) ;
const [ expirationDate , setExpirationDate ] = useState ( '' ) ;
const [ scopeOverride , setScopeOverride ] = useState ( '' ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ errors , setErrors ] = useState ( { } ) ;
const [ result , setResult ] = useState ( null ) ;
const [ files , setFiles ] = useState ( [ ] ) ;
2026-04-15 15:27:21 -06:00
const [ libraryDocs , setLibraryDocs ] = useState ( [ ] ) ;
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
const [ additionalFindingIds , setAdditionalFindingIds ] = useState ( new Set ( ) ) ;
const [ statusValue , setStatusValue ] = useState ( '' ) ;
// Reset form when submission changes
useEffect ( ( ) => {
if ( submission ) {
setName ( submission . workflow _name || '' ) ;
setReason ( submission . reason || '' ) ;
setDescription ( submission . description || '' ) ;
setExpirationDate ( submission . expiration _date || '' ) ;
setScopeOverride ( submission . scope _override || '' ) ;
setStatusValue ( submission . lifecycle _status || 'submitted' ) ;
setActiveTab ( 'details' ) ;
setErrors ( { } ) ;
setResult ( null ) ;
setFiles ( [ ] ) ;
2026-04-15 15:27:21 -06:00
setLibraryDocs ( [ ] ) ;
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
setAdditionalFindingIds ( new Set ( ) ) ;
}
} , [ submission ] ) ;
if ( ! open || ! submission ) return null ;
const isApproved = ( submission . lifecycle _status || '' ) . toLowerCase ( ) === 'approved' ;
const currentFindings = ( ( ) => {
try { return JSON . parse ( submission . finding _ids _json || '[]' ) ; } catch { return [ ] ; }
} ) ( ) ;
const existingAttachments = ( ( ) => {
try { return JSON . parse ( submission . attachment _results _json || '[]' ) ; } catch { return [ ] ; }
} ) ( ) ;
const history = submission . history || [ ] ;
const pendingFpQueue = ( queueItems || [ ] ) . filter ( i =>
i . workflow _type === 'FP' && i . status === 'pending' && ! currentFindings . includes ( String ( i . finding _id ) )
) ;
const handleSaveDetails = async ( ) => {
setSaving ( true ) ; setErrors ( { } ) ; setResult ( null ) ;
try {
const res = await fetch ( ` ${ API _BASE } /ivanti/fp-workflow/submissions/ ${ submission . id } ` , {
method : 'PUT' , credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { name , reason , description , expirationDate , scopeOverride } ) ,
} ) ;
const data = await res . json ( ) ;
if ( res . ok ) {
setResult ( { type : 'success' , message : 'Details saved successfully.' } ) ;
if ( onSuccess ) onSuccess ( ) ;
} else {
setResult ( { type : 'error' , message : data . error || 'Failed to save details.' } ) ;
}
} catch ( e ) {
setResult ( { type : 'error' , message : 'Network error saving details.' } ) ;
} finally { setSaving ( false ) ; }
} ;
const handleAddFindings = async ( ) => {
if ( additionalFindingIds . size === 0 ) return ;
setSaving ( true ) ; setResult ( null ) ;
const selectedItems = pendingFpQueue . filter ( i => additionalFindingIds . has ( i . id ) ) ;
const findingIds = selectedItems . map ( i => String ( i . finding _id ) ) ;
const queueItemIds = selectedItems . map ( i => i . id ) ;
try {
const res = await fetch ( ` ${ API _BASE } /ivanti/fp-workflow/submissions/ ${ submission . id } /findings ` , {
method : 'POST' , credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { findingIds , queueItemIds } ) ,
} ) ;
const data = await res . json ( ) ;
if ( res . ok ) {
setResult ( { type : 'success' , message : ` Added ${ findingIds . length } finding(s). ` } ) ;
setAdditionalFindingIds ( new Set ( ) ) ;
if ( onSuccess ) onSuccess ( ) ;
} else {
setResult ( { type : 'error' , message : data . error || 'Failed to add findings.' } ) ;
}
} catch ( e ) {
setResult ( { type : 'error' , message : 'Network error adding findings.' } ) ;
} finally { setSaving ( false ) ; }
} ;
const handleUploadAttachments = async ( ) => {
2026-04-15 15:27:21 -06:00
if ( files . length === 0 && libraryDocs . length === 0 ) return ;
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
setSaving ( true ) ; setResult ( null ) ;
const formData = new FormData ( ) ;
2026-04-13 12:45:37 -06:00
files . forEach ( f => formData . append ( 'attachments' , f ) ) ;
2026-04-15 15:27:21 -06:00
if ( libraryDocs . length > 0 ) {
formData . append ( 'libraryDocIds' , JSON . stringify ( libraryDocs . map ( d => d . id ) ) ) ;
}
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
try {
const res = await fetch ( ` ${ API _BASE } /ivanti/fp-workflow/submissions/ ${ submission . id } /attachments ` , {
method : 'POST' , credentials : 'include' , body : formData ,
} ) ;
const data = await res . json ( ) ;
if ( res . ok ) {
const successCount = ( data . attachmentResults || [ ] ) . filter ( r => r . success ) . length ;
setResult ( { type : 'success' , message : ` Uploaded ${ successCount } file(s). ` } ) ;
setFiles ( [ ] ) ;
2026-04-15 15:27:21 -06:00
setLibraryDocs ( [ ] ) ;
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
if ( onSuccess ) onSuccess ( ) ;
} else {
setResult ( { type : 'error' , message : data . error || 'Failed to upload attachments.' } ) ;
}
} catch ( e ) {
setResult ( { type : 'error' , message : 'Network error uploading attachments.' } ) ;
} finally { setSaving ( false ) ; }
} ;
const handleStatusChange = async ( newStatus ) => {
setSaving ( true ) ; setResult ( null ) ;
try {
const res = await fetch ( ` ${ API _BASE } /ivanti/fp-workflow/submissions/ ${ submission . id } /status ` , {
method : 'PATCH' , credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { lifecycle _status : newStatus } ) ,
} ) ;
const data = await res . json ( ) ;
if ( res . ok ) {
setResult ( { type : 'success' , message : ` Status changed to ${ newStatus } . ` } ) ;
setStatusValue ( newStatus ) ;
if ( onSuccess ) onSuccess ( ) ;
} else {
setResult ( { type : 'error' , message : data . error || 'Failed to change status.' } ) ;
}
} catch ( e ) {
setResult ( { type : 'error' , message : 'Network error changing status.' } ) ;
} finally { setSaving ( false ) ; }
} ;
const lsBadge = lifecycleStatusBadge ( statusValue ) ;
const tabs = [ 'details' , 'findings' , 'attachments' , 'history' ] ;
const inputStyle = {
width : '100%' , boxSizing : 'border-box' ,
background : isApproved ? 'rgba(100,116,139,0.06)' : 'rgba(14,165,233,0.05)' ,
border : ` 1px solid ${ isApproved ? 'rgba(100,116,139,0.15)' : 'rgba(14,165,233,0.2)' } ` ,
borderRadius : '0.25rem' , padding : '0.4rem 0.5rem' ,
color : isApproved ? '#64748B' : '#CBD5E1' ,
fontSize : '0.78rem' , fontFamily : 'monospace' , outline : 'none' ,
} ;
const labelStyle = { display : 'block' , fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' , color : '#94A3B8' , marginBottom : '0.25rem' , textTransform : 'uppercase' , letterSpacing : '0.06em' } ;
return ReactDOM . createPortal (
< div style = { { position : 'fixed' , inset : 0 , zIndex : 10010 , display : 'flex' , alignItems : 'center' , justifyContent : 'center' , background : 'rgba(0,0,0,0.6)' } } onClick = { onClose } >
< div onClick = { ( e ) => e . stopPropagation ( ) } style = { {
width : '640px' , maxHeight : '85vh' , display : 'flex' , flexDirection : 'column' ,
background : 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)' ,
border : '1px solid rgba(14,165,233,0.2)' ,
borderRadius : '0.5rem' ,
boxShadow : '0 20px 60px rgba(0,0,0,0.8)' ,
} } >
{ /* Header */ }
< div style = { { padding : '1rem 1.25rem' , borderBottom : '1px solid rgba(14,165,233,0.15)' , flexShrink : 0 } } >
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : '0.625rem' } } >
< Edit3 style = { { width : '18px' , height : '18px' , color : '#F59E0B' } } / >
< span style = { { fontFamily : 'monospace' , fontSize : '0.9rem' , fontWeight : '700' , color : '#E2E8F0' } } >
Edit FP Workflow
< / s p a n >
< span style = { {
padding : '0.1rem 0.4rem' , borderRadius : '0.2rem' ,
background : lsBadge . bg , border : ` 1px solid ${ lsBadge . border } ` ,
color : lsBadge . text , fontFamily : 'monospace' , fontSize : '0.6rem' , fontWeight : '700' ,
textTransform : 'uppercase' ,
} } >
{ statusValue }
< / s p a n >
< / d i v >
< button onClick = { onClose } style = { { background : 'none' , border : 'none' , cursor : 'pointer' , color : '#475569' , padding : '4px' , lineHeight : 1 } } >
< X style = { { width : '18px' , height : '18px' } } / >
< / b u t t o n >
< / d i v >
< div style = { { fontFamily : 'monospace' , fontSize : '0.72rem' , color : '#64748B' , marginTop : '0.25rem' } } >
{ submission . workflow _name || ` Batch # ${ submission . ivanti _workflow _batch _id } ` }
< / d i v >
{ isApproved && (
< div style = { { marginTop : '0.5rem' , padding : '0.35rem 0.5rem' , borderRadius : '0.25rem' , background : 'rgba(16,185,129,0.08)' , border : '1px solid rgba(16,185,129,0.2)' , fontFamily : 'monospace' , fontSize : '0.68rem' , color : '#10B981' } } >
This submission is finalized and cannot be edited .
< / d i v >
) }
< / d i v >
{ /* Tab bar */ }
< div style = { { display : 'flex' , borderBottom : '1px solid rgba(255,255,255,0.06)' , flexShrink : 0 } } >
{ tabs . map ( tab => (
< button key = { tab } onClick = { ( ) => { setActiveTab ( tab ) ; setResult ( null ) ; } } style = { {
flex : 1 , padding : '0.5rem' , background : 'none' ,
border : 'none' , borderBottom : activeTab === tab ? '2px solid #0EA5E9' : '2px solid transparent' ,
color : activeTab === tab ? '#0EA5E9' : '#475569' ,
fontFamily : 'monospace' , fontSize : '0.72rem' , fontWeight : '600' ,
cursor : 'pointer' , textTransform : 'uppercase' , letterSpacing : '0.05em' ,
transition : 'all 0.12s' ,
} } >
{ tab }
< / b u t t o n >
) ) }
< / d i v >
{ /* Status change row */ }
{ ! isApproved && (
< div style = { { padding : '0.5rem 1.25rem' , borderBottom : '1px solid rgba(255,255,255,0.04)' , display : 'flex' , alignItems : 'center' , gap : '0.5rem' , flexShrink : 0 } } >
< span style = { { fontFamily : 'monospace' , fontSize : '0.68rem' , color : '#64748B' } } > Status : < / s p a n >
< select
value = { statusValue }
onChange = { ( e ) => handleStatusChange ( e . target . value ) }
disabled = { saving }
style = { {
background : 'rgba(14,165,233,0.05)' , border : '1px solid rgba(14,165,233,0.2)' ,
borderRadius : '0.25rem' , padding : '0.25rem 0.4rem' ,
color : '#CBD5E1' , fontSize : '0.72rem' , fontFamily : 'monospace' , outline : 'none' ,
cursor : saving ? 'not-allowed' : 'pointer' ,
} }
>
{ [ 'submitted' , 'approved' , 'rejected' , 'rework' , 'resubmitted' ] . map ( s => (
< option key = { s } value = { s } > { s } < / o p t i o n >
) ) }
< / s e l e c t >
< / d i v >
) }
{ /* Result banner */ }
{ result && (
< div style = { {
margin : '0.5rem 1.25rem 0' , padding : '0.35rem 0.5rem' , borderRadius : '0.25rem' ,
background : result . type === 'success' ? 'rgba(16,185,129,0.1)' : 'rgba(239,68,68,0.1)' ,
border : ` 1px solid ${ result . type === 'success' ? 'rgba(16,185,129,0.3)' : 'rgba(239,68,68,0.3)' } ` ,
fontFamily : 'monospace' , fontSize : '0.72rem' ,
color : result . type === 'success' ? '#10B981' : '#EF4444' ,
display : 'flex' , alignItems : 'center' , gap : '0.375rem' ,
} } >
{ result . type === 'success' ? < Check style = { { width : '12px' , height : '12px' } } / > : < AlertCircle style = { { width : '12px' , height : '12px' } } / > }
{ result . message }
< / d i v >
) }
{ /* Tab content */ }
< div style = { { flex : 1 , overflowY : 'auto' , padding : '1rem 1.25rem' } } >
{ /* Details tab */ }
{ activeTab === 'details' && (
< div style = { { display : 'flex' , flexDirection : 'column' , gap : '0.75rem' } } >
< div >
< label style = { labelStyle } > Workflow Name < / l a b e l >
< input type = "text" value = { name } onChange = { ( e ) => setName ( e . target . value ) } disabled = { isApproved } style = { inputStyle } / >
< / d i v >
< div >
< label style = { labelStyle } > Reason < / l a b e l >
< select value = { reason } onChange = { ( e ) => setReason ( e . target . value ) } disabled = { isApproved } style = { inputStyle } >
< option value = "" > Select reason … < / o p t i o n >
< option value = "Scanner false positive" > Scanner false positive < / o p t i o n >
< option value = "Compensating control" > Compensating control < / o p t i o n >
< option value = "Risk accepted" > Risk accepted < / o p t i o n >
< option value = "Not applicable" > Not applicable < / o p t i o n >
< option value = "Other" > Other < / o p t i o n >
< / s e l e c t >
< / d i v >
< div >
< label style = { labelStyle } > Description < / l a b e l >
< textarea value = { description } onChange = { ( e ) => setDescription ( e . target . value ) } disabled = { isApproved } rows = { 3 } style = { { ... inputStyle , resize : 'vertical' } } / >
< / d i v >
< div style = { { display : 'flex' , gap : '0.75rem' } } >
< div style = { { flex : 1 } } >
< label style = { labelStyle } > Expiration Date < / l a b e l >
2026-04-22 19:52:06 +00:00
< input type = "date" value = { expirationDate } onChange = { ( e ) => setExpirationDate ( e . target . value ) } disabled = { isApproved } min = { ( ( ) => { const d = new Date ( ) ; d . setDate ( d . getDate ( ) + 1 ) ; return d . toISOString ( ) . split ( 'T' ) [ 0 ] ; } ) ( ) } max = { ( ( ) => { const d = new Date ( ) ; d . setDate ( d . getDate ( ) + 120 ) ; return d . toISOString ( ) . split ( 'T' ) [ 0 ] ; } ) ( ) } style = { inputStyle } / >
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
< / d i v >
< div style = { { flex : 1 } } >
< label style = { labelStyle } > Scope Override < / l a b e l >
< select value = { scopeOverride } onChange = { ( e ) => setScopeOverride ( e . target . value ) } disabled = { isApproved } style = { inputStyle } >
< option value = "" > Default < / o p t i o n >
< option value = "Authorized" > Authorized < / o p t i o n >
< option value = "Unauthorized" > Unauthorized < / o p t i o n >
< / s e l e c t >
< / d i v >
< / d i v >
{ ! isApproved && (
< button onClick = { handleSaveDetails } disabled = { saving } style = { {
alignSelf : 'flex-end' , padding : '0.4rem 1rem' ,
background : saving ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)' ,
border : ` 1px solid ${ saving ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)' } ` ,
borderRadius : '0.375rem' , color : saving ? '#92700C' : '#F59E0B' ,
fontSize : '0.75rem' , fontWeight : '700' , cursor : saving ? 'not-allowed' : 'pointer' ,
fontFamily : 'monospace' , textTransform : 'uppercase' , letterSpacing : '0.05em' ,
} } >
{ saving ? 'Saving…' : 'Save Details' }
< / b u t t o n >
) }
< / d i v >
) }
{ /* Findings tab */ }
{ activeTab === 'findings' && (
< div >
< div style = { { marginBottom : '0.75rem' } } >
< span style = { labelStyle } > Current Findings ( { currentFindings . length } ) < / s p a n >
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '0.25rem' , marginTop : '0.25rem' } } >
{ currentFindings . length === 0 ? (
< span style = { { fontFamily : 'monospace' , fontSize : '0.72rem' , color : '#475569' } } > No findings mapped . < / s p a n >
) : currentFindings . map ( fid => (
< span key = { fid } style = { {
padding : '0.1rem 0.35rem' , borderRadius : '0.2rem' ,
background : 'rgba(14,165,233,0.08)' , border : '1px solid rgba(14,165,233,0.2)' ,
fontFamily : 'monospace' , fontSize : '0.65rem' , color : '#0EA5E9' ,
} } >
{ fid }
< / s p a n >
) ) }
< / d i v >
< / d i v >
{ ! isApproved && pendingFpQueue . length > 0 && (
< div >
< span style = { labelStyle } > Add Pending FP Queue Items < / s p a n >
< div style = { { maxHeight : '200px' , overflowY : 'auto' , marginTop : '0.25rem' } } >
{ pendingFpQueue . map ( item => (
< label key = { item . id } style = { {
display : 'flex' , alignItems : 'center' , gap : '0.5rem' ,
padding : '0.35rem 0.5rem' , marginBottom : '0.15rem' ,
borderRadius : '0.25rem' ,
background : additionalFindingIds . has ( item . id ) ? 'rgba(245,158,11,0.08)' : 'transparent' ,
border : ` 1px solid ${ additionalFindingIds . has ( item . id ) ? 'rgba(245,158,11,0.2)' : 'rgba(255,255,255,0.04)' } ` ,
cursor : 'pointer' ,
} } >
< input type = "checkbox" checked = { additionalFindingIds . has ( item . id ) }
onChange = { ( ) => setAdditionalFindingIds ( prev => {
const next = new Set ( prev ) ;
if ( next . has ( item . id ) ) next . delete ( item . id ) ; else next . add ( item . id ) ;
return next ;
} ) }
style = { { accentColor : '#F59E0B' , width : '13px' , height : '13px' } }
/ >
< span style = { { fontFamily : 'monospace' , fontSize : '0.72rem' , color : '#CBD5E1' } } > { item . finding _id } < / s p a n >
{ item . hostname && < span style = { { fontFamily : 'monospace' , fontSize : '0.62rem' , color : '#64748B' } } > { item . hostname } < / s p a n > }
< / l a b e l >
) ) }
< / d i v >
< button onClick = { handleAddFindings } disabled = { saving || additionalFindingIds . size === 0 } style = { {
marginTop : '0.5rem' , padding : '0.4rem 1rem' ,
background : additionalFindingIds . size > 0 ? 'rgba(245,158,11,0.15)' : 'transparent' ,
border : ` 1px solid ${ additionalFindingIds . size > 0 ? 'rgba(245,158,11,0.4)' : 'rgba(255,255,255,0.05)' } ` ,
borderRadius : '0.375rem' ,
color : additionalFindingIds . size > 0 ? '#F59E0B' : '#334155' ,
fontSize : '0.75rem' , fontWeight : '700' , cursor : additionalFindingIds . size > 0 ? 'pointer' : 'not-allowed' ,
fontFamily : 'monospace' , textTransform : 'uppercase' , letterSpacing : '0.05em' ,
} } >
{ saving ? 'Adding…' : ` Add ${ additionalFindingIds . size } Finding(s) ` }
< / b u t t o n >
< / d i v >
) }
{ ! isApproved && pendingFpQueue . length === 0 && (
< span style = { { fontFamily : 'monospace' , fontSize : '0.72rem' , color : '#475569' } } > No pending FP queue items available to add . < / s p a n >
) }
< / d i v >
) }
{ /* Attachments tab */ }
{ activeTab === 'attachments' && (
< div >
< div style = { { marginBottom : '0.75rem' } } >
2026-04-13 14:10:55 -06:00
< span style = { labelStyle } > Attachments from Initial Submission ( { existingAttachments . length } ) < / s p a n >
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
{ existingAttachments . length === 0 ? (
2026-04-13 14:10:55 -06:00
< div style = { { fontFamily : 'monospace' , fontSize : '0.72rem' , color : '#475569' , marginTop : '0.25rem' } } > No attachments were included in the original submission . < / d i v >
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
) : (
< div style = { { marginTop : '0.25rem' } } >
{ existingAttachments . map ( ( att , idx ) => (
< div key = { idx } style = { {
display : 'flex' , alignItems : 'center' , gap : '0.5rem' ,
padding : '0.3rem 0.5rem' , marginBottom : '0.15rem' ,
borderRadius : '0.25rem' ,
background : 'rgba(14,165,233,0.04)' ,
border : '1px solid rgba(14,165,233,0.1)' ,
} } >
< FileText style = { { width : '12px' , height : '12px' , color : '#0EA5E9' , flexShrink : 0 } } / >
< span style = { { fontFamily : 'monospace' , fontSize : '0.72rem' , color : '#CBD5E1' , flex : 1 } } > { att . filename } < / s p a n >
< span style = { {
fontFamily : 'monospace' , fontSize : '0.6rem' , fontWeight : '600' ,
color : att . success ? '#10B981' : '#EF4444' ,
} } >
{ att . success ? 'OK' : 'FAILED' }
< / s p a n >
< / d i v >
) ) }
< / d i v >
) }
< / d i v >
2026-04-15 15:27:21 -06:00
{ ! isApproved && (
< div style = { { marginTop : '0.75rem' } } >
< AttachmentSourcePicker
files = { files }
onFilesChange = { setFiles }
libraryDocs = { libraryDocs }
onLibraryDocsChange = { setLibraryDocs }
disabled = { isApproved }
/ >
{ ( files . length > 0 || libraryDocs . length > 0 ) && (
< button
onClick = { handleUploadAttachments }
disabled = { saving }
style = { {
marginTop : '0.5rem' ,
padding : '0.4rem 1rem' ,
background : saving ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)' ,
border : ` 1px solid ${ saving ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)' } ` ,
borderRadius : '0.375rem' ,
color : saving ? '#92700C' : '#F59E0B' ,
fontSize : '0.75rem' ,
fontWeight : '700' ,
cursor : saving ? 'not-allowed' : 'pointer' ,
fontFamily : 'monospace' ,
textTransform : 'uppercase' ,
letterSpacing : '0.05em' ,
} }
>
{ saving ? 'Uploading…' : ` Upload ${ files . length + libraryDocs . length } Attachment(s) ` }
< / b u t t o n >
) }
< / d i v >
) }
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
< / d i v >
) }
{ /* History tab */ }
{ activeTab === 'history' && (
< div >
2026-04-13 16:14:27 -06:00
{ /* Ivanti reviewer notes (rework/approval/previous state feedback) */ }
{ ( ( ) => {
const notes = [
submission . ivanti _rework _note && { label : 'Rework Note' , text : submission . ivanti _rework _note } ,
submission . ivanti _approval _note && { label : 'Approval Note' , text : submission . ivanti _approval _note } ,
submission . ivanti _current _state _notes && { label : 'Current State Notes' , text : submission . ivanti _current _state _notes } ,
submission . ivanti _previous _state _notes && { label : 'Previous State Notes' , text : submission . ivanti _previous _state _notes } ,
] . filter ( Boolean ) ;
return notes . length > 0 ? notes . map ( ( note , idx ) => (
< div key = { idx } style = { {
padding : '0.625rem 0.75rem' , marginBottom : '0.5rem' ,
borderRadius : '0.375rem' ,
background : 'rgba(245,158,11,0.06)' , border : '1px solid rgba(245,158,11,0.2)' ,
} } >
< div style = { { fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' , color : '#F59E0B' , textTransform : 'uppercase' , letterSpacing : '0.06em' , marginBottom : '0.35rem' } } >
{ note . label }
< / d i v >
< div style = { { fontFamily : 'monospace' , fontSize : '0.72rem' , color : '#CBD5E1' , whiteSpace : 'pre-wrap' , lineHeight : 1.5 } } >
{ note . text }
< / d i v >
2026-04-13 14:25:14 -06:00
< / d i v >
2026-04-13 16:14:27 -06:00
) ) : null ;
} ) ( ) }
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
{ history . length === 0 ? (
< div style = { { fontFamily : 'monospace' , fontSize : '0.72rem' , color : '#475569' , textAlign : 'center' , padding : '2rem 0' } } >
No history entries .
< / d i v >
) : history . map ( ( entry , idx ) => {
const details = ( ( ) => {
try { return JSON . parse ( entry . change _details _json || '{}' ) ; } catch { return { } ; }
} ) ( ) ;
return (
< div key = { entry . id || idx } style = { {
padding : '0.5rem 0.625rem' , marginBottom : '0.35rem' ,
borderRadius : '0.25rem' ,
background : 'rgba(14,165,233,0.04)' ,
border : '1px solid rgba(14,165,233,0.08)' ,
} } >
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' } } >
< span style = { {
fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' ,
color : '#0EA5E9' , textTransform : 'uppercase' ,
} } >
{ ( entry . change _type || '' ) . replace ( /_/g , ' ' ) }
< / s p a n >
< span style = { { fontFamily : 'monospace' , fontSize : '0.62rem' , color : '#475569' } } >
{ entry . created _at ? new Date ( entry . created _at ) . toLocaleString ( ) : '' }
< / s p a n >
< / d i v >
< div style = { { fontFamily : 'monospace' , fontSize : '0.65rem' , color : '#64748B' , marginTop : '0.2rem' } } >
by { entry . username || 'unknown' }
< / d i v >
{ entry . change _type === 'status_changed' && details . from && (
< div style = { { fontFamily : 'monospace' , fontSize : '0.65rem' , color : '#94A3B8' , marginTop : '0.15rem' } } >
{ details . from } → { details . to }
< / d i v >
) }
{ entry . change _type === 'findings_added' && details . addedFindingIds && (
2026-04-13 14:25:14 -06:00
< div style = { { marginTop : '0.15rem' } } >
< div style = { { fontFamily : 'monospace' , fontSize : '0.65rem' , color : '#94A3B8' } } >
+ { details . addedFindingIds . length } finding ( s ) :
< / d i v >
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '0.2rem' , marginTop : '0.2rem' } } >
{ details . addedFindingIds . map ( fid => (
< span key = { fid } style = { {
padding : '0.05rem 0.3rem' , borderRadius : '0.15rem' ,
background : 'rgba(14,165,233,0.08)' , border : '1px solid rgba(14,165,233,0.2)' ,
fontFamily : 'monospace' , fontSize : '0.6rem' , color : '#0EA5E9' ,
} } >
{ fid }
< / s p a n >
) ) }
< / d i v >
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
< / d i v >
) }
{ entry . change _type === 'attachments_added' && details . files && (
< div style = { { fontFamily : 'monospace' , fontSize : '0.65rem' , color : '#94A3B8' , marginTop : '0.15rem' } } >
2026-04-13 14:25:14 -06:00
{ details . files . filter ( f => f . success ) . length } of { details . files . length } file ( s ) uploaded
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
< / d i v >
) }
< / d i v >
) ;
} ) }
< / d i v >
) }
< / d i v >
< / d i v >
< / d i v > ,
document . body
) ;
}
2026-04-09 09:49:40 -06:00
// ---------------------------------------------------------------------------
// SelectionToolbar — batch action bar for multi-selected findings
// ---------------------------------------------------------------------------
function SelectionToolbar ( { count , workflowType , vendor , submitting , error , onWorkflowChange , onVendorChange , onSubmit , onClear } ) {
2026-04-14 15:33:19 -06:00
const isCard = workflowType === 'CARD' || workflowType === 'GRANITE' ;
2026-04-09 09:49:40 -06:00
const canSubmit = ! submitting && ( isCard || vendor . trim ( ) . length > 0 ) ;
return (
< div style = { {
position : 'sticky' , top : 0 , zIndex : 20 ,
display : 'flex' , alignItems : 'center' , gap : '0.75rem' , flexWrap : 'wrap' ,
padding : '0.625rem 1rem' ,
background : 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)' ,
border : '1px solid rgba(14,165,233,0.25)' ,
borderRadius : '0.375rem' ,
marginBottom : '0.5rem' ,
} } >
{ /* Count badge */ }
< span style = { {
display : 'inline-flex' , alignItems : 'center' , gap : '0.375rem' ,
fontFamily : 'monospace' , fontSize : '0.75rem' , fontWeight : '700' , color : '#E2E8F0' ,
} } >
< span style = { {
display : 'inline-flex' , alignItems : 'center' , justifyContent : 'center' ,
minWidth : '22px' , height : '22px' , padding : '0 6px' ,
background : 'rgba(14,165,233,0.2)' , border : '1px solid rgba(14,165,233,0.4)' ,
borderRadius : '999px' , fontFamily : 'monospace' , fontSize : '0.7rem' , fontWeight : '700' , color : '#0EA5E9' ,
} } >
{ count }
< / s p a n >
selected
< / s p a n >
{ /* Workflow type toggles */ }
< div style = { { display : 'flex' , gap : '0.25rem' } } >
{ [
{ type : 'FP' , color : '#F59E0B' , rgb : '245,158,11' } ,
{ type : 'Archer' , color : '#0EA5E9' , rgb : '14,165,233' } ,
{ type : 'CARD' , color : '#10B981' , rgb : '16,185,129' } ,
2026-04-14 15:33:19 -06:00
{ type : 'GRANITE' , color : '#A1887F' , rgb : '161,136,127' } ,
2026-04-09 09:49:40 -06:00
] . map ( ( { type , color , rgb } ) => {
const active = workflowType === type ;
return (
< button
key = { type }
onClick = { ( ) => onWorkflowChange ( type ) }
style = { {
padding : '0.25rem 0.5rem' ,
background : active ? ` rgba( ${ rgb } ,0.2) ` : 'transparent' ,
border : ` 1px solid rgba( ${ rgb } , ${ active ? '0.5' : '0.15' } ) ` ,
borderRadius : '0.25rem' ,
color : active ? color : '#475569' ,
fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '700' ,
cursor : 'pointer' , textTransform : 'uppercase' , letterSpacing : '0.05em' ,
transition : 'all 0.12s' ,
} }
>
{ type }
< / b u t t o n >
) ;
} ) }
< / d i v >
{ /* Vendor input or CARD indicator */ }
{ isCard ? (
< span style = { {
fontFamily : 'monospace' , fontSize : '0.68rem' , color : '#10B981' ,
padding : '0.25rem 0.5rem' ,
background : 'rgba(16,185,129,0.06)' , border : '1px solid rgba(16,185,129,0.2)' ,
borderRadius : '0.25rem' ,
} } >
No vendor required
< / s p a n >
) : (
< input
type = "text"
value = { vendor }
onChange = { ( e ) => onVendorChange ( e . target . value ) }
placeholder = "Vendor / Platform"
style = { {
width : '160px' , boxSizing : 'border-box' ,
background : 'rgba(14,165,233,0.05)' , border : '1px solid rgba(14,165,233,0.2)' ,
borderRadius : '0.25rem' , padding : '0.3rem 0.5rem' ,
color : '#CBD5E1' , fontSize : '0.75rem' , fontFamily : 'monospace' , outline : 'none' ,
} }
onKeyDown = { ( e ) => { if ( e . key === 'Enter' && canSubmit ) onSubmit ( ) ; } }
/ >
) }
{ /* Add to Queue button */ }
< button
onClick = { onSubmit }
disabled = { ! canSubmit }
style = { {
padding : '0.3rem 0.75rem' ,
background : canSubmit ? 'rgba(14,165,233,0.15)' : 'transparent' ,
border : ` 1px solid rgba(14,165,233, ${ canSubmit ? '0.4' : '0.1' } ) ` ,
borderRadius : '0.25rem' ,
color : canSubmit ? '#0EA5E9' : '#334155' ,
fontFamily : 'monospace' , fontSize : '0.72rem' , fontWeight : '600' ,
cursor : canSubmit ? 'pointer' : 'not-allowed' ,
textTransform : 'uppercase' , letterSpacing : '0.05em' ,
transition : 'all 0.12s' ,
} }
>
{ submitting ? 'Adding…' : 'Add to Queue' }
< / b u t t o n >
{ /* Clear selection */ }
< button
onClick = { onClear }
style = { {
background : 'none' , border : 'none' , cursor : 'pointer' ,
color : '#475569' , padding : '4px' , lineHeight : 1 ,
} }
title = "Clear selection"
>
< X style = { { width : '16px' , height : '16px' } } / >
< / b u t t o n >
{ /* Error message */ }
{ error && (
< span style = { {
fontFamily : 'monospace' , fontSize : '0.68rem' , color : '#EF4444' ,
display : 'flex' , alignItems : 'center' , gap : '0.25rem' ,
} } >
< AlertCircle style = { { width : '12px' , height : '12px' } } / >
{ error }
< / s p a n >
) }
< / d i v >
) ;
}
2026-04-15 13:15:01 -06:00
// ---------------------------------------------------------------------------
// RowVisibilityManager — popover for viewing and restoring hidden rows
// ---------------------------------------------------------------------------
function RowVisibilityManager ( { hiddenRowIds , findings , onRestore , onRestoreAll } ) {
const [ open , setOpen ] = useState ( false ) ;
const panelRef = useRef ( null ) ;
const btnRef = useRef ( null ) ;
// Close on outside click (same pattern as ColumnManager)
useEffect ( ( ) => {
if ( ! open ) return ;
const handler = ( e ) => {
if ( ! panelRef . current ? . contains ( e . target ) && ! btnRef . current ? . contains ( e . target ) ) setOpen ( false ) ;
} ;
document . addEventListener ( 'mousedown' , handler ) ;
return ( ) => document . removeEventListener ( 'mousedown' , handler ) ;
} , [ open ] ) ;
const hiddenCount = hiddenRowIds . size ;
// Build list of hidden findings with title lookup
const hiddenEntries = useMemo ( ( ) => {
const ids = [ ... hiddenRowIds ] ;
return ids . map ( id => {
const finding = findings . find ( f => String ( f . id ) === String ( id ) ) ;
return { id , title : finding ? finding . title : null } ;
} ) ;
} , [ hiddenRowIds , findings ] ) ;
return (
< div style = { { position : 'relative' } } >
< button
ref = { btnRef }
onClick = { ( ) => setOpen ( p => ! p ) }
style = { {
display : 'flex' , alignItems : 'center' , gap : '0.375rem' ,
padding : '0.35rem 0.75rem' ,
background : open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.08)' ,
border : ` 1px solid rgba(14,165,233, ${ open ? '0.5' : '0.2' } ) ` ,
borderRadius : '0.375rem' ,
color : '#94a3b8' , cursor : 'pointer' ,
fontFamily : 'monospace' , fontSize : '0.65rem' , fontWeight : '600' ,
textTransform : 'uppercase' , letterSpacing : '0.05em' ,
} }
>
< EyeOff style = { { width : '13px' , height : '13px' } } / >
Hidden ( { hiddenCount } )
< / b u t t o n >
{ open && (
< div
ref = { panelRef }
style = { {
position : 'absolute' , top : 'calc(100% + 8px)' , right : 0 ,
width : '300px' , zIndex : 100 ,
background : 'linear-gradient(135deg, rgba(15,23,42,0.98), rgba(30,41,59,0.98))' ,
border : '1px solid rgba(14,165,233,0.25)' ,
borderRadius : '8px' ,
boxShadow : '0 8px 32px rgba(0,0,0,0.5)' ,
padding : '0.5rem' ,
maxHeight : '320px' ,
overflowY : 'auto' ,
} }
>
{ /* Header */ }
< div style = { {
fontSize : '0.65rem' , color : '#475569' , fontFamily : 'monospace' ,
textTransform : 'uppercase' , letterSpacing : '0.1em' ,
padding : '0.25rem 0.5rem 0.5rem' ,
borderBottom : '1px solid rgba(255,255,255,0.05)' ,
marginBottom : '0.375rem' ,
} } >
Hidden Rows
< / d i v >
{ hiddenCount === 0 ? (
< div style = { {
padding : '1rem 0.5rem' ,
textAlign : 'center' ,
fontFamily : 'monospace' , fontSize : '0.7rem' , color : '#475569' ,
} } >
No rows hidden
< / d i v >
) : (
< >
{ hiddenEntries . map ( entry => (
< div
key = { entry . id }
style = { {
display : 'flex' , alignItems : 'center' , gap : '0.5rem' ,
padding : '0.4rem 0.5rem' , borderRadius : '0.25rem' ,
transition : 'background 0.1s' ,
} }
>
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { {
fontFamily : 'monospace' , fontSize : '0.7rem' , fontWeight : '600' ,
color : '#CBD5E1' , whiteSpace : 'nowrap' , overflow : 'hidden' , textOverflow : 'ellipsis' ,
} } >
{ entry . id }
< / d i v >
{ entry . title && (
< div style = { {
fontFamily : 'monospace' , fontSize : '0.62rem' , color : '#64748B' ,
whiteSpace : 'nowrap' , overflow : 'hidden' , textOverflow : 'ellipsis' ,
marginTop : '1px' ,
} } >
{ entry . title }
< / d i v >
) }
< / d i v >
< button
onClick = { ( ) => onRestore ( entry . id ) }
title = "Restore row"
style = { {
background : 'none' , border : 'none' , cursor : 'pointer' ,
padding : '2px' , color : '#334155' , lineHeight : 1 ,
transition : 'color 0.15s' ,
} }
onMouseEnter = { e => { e . currentTarget . style . color = '#0EA5E9' ; } }
onMouseLeave = { e => { e . currentTarget . style . color = '#334155' ; } }
>
< Eye style = { { width : '14px' , height : '14px' } } / >
< / b u t t o n >
< / d i v >
) ) }
{ /* Restore All button */ }
< div style = { {
borderTop : '1px solid rgba(255,255,255,0.05)' ,
marginTop : '0.375rem' ,
paddingTop : '0.375rem' ,
} } >
< button
onClick = { onRestoreAll }
style = { {
width : '100%' ,
display : 'flex' , alignItems : 'center' , justifyContent : 'center' , gap : '0.375rem' ,
padding : '0.4rem 0.5rem' ,
background : 'rgba(14,165,233,0.08)' ,
border : '1px solid rgba(14,165,233,0.2)' ,
borderRadius : '0.25rem' ,
color : '#0EA5E9' , cursor : 'pointer' ,
fontFamily : 'monospace' , fontSize : '0.65rem' , fontWeight : '600' ,
textTransform : 'uppercase' , letterSpacing : '0.05em' ,
transition : 'all 0.15s' ,
} }
>
< RotateCcw style = { { width : '12px' , height : '12px' } } / >
Restore All
< / b u t t o n >
< / d i v >
< / >
) }
< / d i v >
) }
< / d i v >
) ;
}
// ---------------------------------------------------------------------------
// BulkHideToolbar — appears when rows are selected for bulk hiding
// ---------------------------------------------------------------------------
function BulkHideToolbar ( { count , onHide , onClear } ) {
return (
< div style = { {
display : 'flex' , alignItems : 'center' , gap : '0.75rem' ,
padding : '0.5rem 1rem' ,
background : 'linear-gradient(135deg, rgba(15,23,42,0.95), rgba(30,41,59,0.95))' ,
border : '1px solid rgba(14,165,233,0.3)' ,
borderRadius : '6px' ,
marginBottom : '0.5rem' ,
} } >
{ /* Count label */ }
< span style = { {
fontFamily : 'monospace' , fontSize : '0.7rem' , fontWeight : '600' , color : '#e2e8f0' ,
} } >
{ count } row { count !== 1 ? 's' : '' } selected
< / s p a n >
{ /* Hide Selected button */ }
< button
onClick = { onHide }
style = { {
display : 'inline-flex' , alignItems : 'center' , gap : '0.375rem' ,
padding : '0.3rem 0.625rem' ,
background : 'rgba(14,165,233,0.12)' ,
border : '1px solid rgba(79,195,247,0.35)' ,
borderRadius : '0.25rem' ,
color : '#4fc3f7' ,
fontFamily : 'monospace' , fontSize : '0.7rem' , fontWeight : '600' ,
cursor : 'pointer' ,
transition : 'all 0.15s' ,
} }
>
< EyeOff style = { { width : '12px' , height : '12px' } } / >
Hide Selected
< / b u t t o n >
{ /* Clear button */ }
< button
onClick = { onClear }
style = { {
background : 'none' , border : 'none' ,
fontFamily : 'monospace' , fontSize : '0.7rem' , color : '#64748B' ,
cursor : 'pointer' , padding : '0.3rem 0.375rem' ,
transition : 'color 0.15s' ,
} }
>
Clear
< / b u t t o n >
< / d i v >
) ;
}
2026-03-11 12:47:11 -06:00
// ---------------------------------------------------------------------------
// Main ReportingPage
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
// ---------------------------------------------------------------------------
2026-04-02 10:12:04 -06:00
export default function VulnerabilityTriagePage ( { filterDate , filterEXC } ) {
2026-03-13 15:39:37 -06:00
const { canWrite } = useAuth ( ) ;
2026-03-11 13:03:17 -06:00
const [ findings , setFindings ] = useState ( [ ] ) ;
const [ total , setTotal ] = useState ( null ) ;
const [ syncedAt , setSyncedAt ] = useState ( null ) ;
const [ syncStatus , setSyncStatus ] = useState ( null ) ;
const [ syncError , setSyncError ] = useState ( null ) ;
const [ loading , setLoading ] = useState ( false ) ;
const [ syncing , setSyncing ] = useState ( false ) ;
2026-03-13 12:23:05 -06:00
const [ statusCounts , setStatusCounts ] = useState ( { open : 0 , closed : 0 } ) ;
const [ countsLoading , setCountsLoading ] = useState ( true ) ;
2026-03-16 12:13:13 -06:00
const [ fpCounts , setFPCounts ] = useState ( { findingCounts : { } , findingTotal : 0 , idCounts : { } , idTotal : 0 } ) ;
2026-03-11 13:03:17 -06:00
const [ sort , setSort ] = useState ( { field : 'severity' , dir : 'desc' } ) ;
const [ columnOrder , setColumnOrder ] = useState ( loadColumnOrder ) ;
2026-03-11 14:09:08 -06:00
const [ columnFilters , setColumnFilters ] = useState ( ( ) =>
filterDate ? { dueDate : new Set ( [ filterDate ] ) } : { }
) ;
2026-03-11 13:03:17 -06:00
const [ openFilter , setOpenFilter ] = useState ( null ) ;
const filterBtnRefs = useRef ( { } ) ;
2026-03-13 13:06:54 -06:00
const [ actionFilter , setActionFilter ] = useState ( null ) ;
const [ excFilter , setExcFilter ] = useState ( filterEXC || null ) ;
2026-03-11 13:03:17 -06:00
2026-04-09 09:49:40 -06:00
const [ selectedIds , setSelectedIds ] = useState ( new Set ( ) ) ;
const [ lastClickedId , setLastClickedId ] = useState ( null ) ;
const [ batchSubmitting , setBatchSubmitting ] = useState ( false ) ;
const [ batchError , setBatchError ] = useState ( null ) ;
const [ batchWorkflowType , setBatchWorkflowType ] = useState ( 'FP' ) ;
const [ batchVendor , setBatchVendor ] = useState ( '' ) ;
2026-04-09 14:42:23 -06:00
// CVE tooltip state & refs
const [ tooltipCveId , setTooltipCveId ] = useState ( null ) ;
const [ tooltipAnchorRect , setTooltipAnchorRect ] = useState ( null ) ;
const tooltipCacheRef = useRef ( new Map ( ) ) ;
const hoverTimerRef = useRef ( null ) ;
2026-03-11 12:47:11 -06:00
const updateColumns = useCallback ( ( newOrder ) => {
setColumnOrder ( newOrder ) ;
saveColumnOrder ( newOrder ) ;
} , [ ] ) ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
2026-04-15 13:15:01 -06:00
// Hidden row state (row visibility feature)
const [ hiddenRowIds , setHiddenRowIds ] = useState ( loadHiddenRows ) ;
const hideRow = useCallback ( ( findingId ) => {
setHiddenRowIds ( prev => {
const next = new Set ( prev ) ;
next . add ( String ( findingId ) ) ;
saveHiddenRows ( next ) ;
return next ;
} ) ;
} , [ ] ) ;
const restoreRow = useCallback ( ( findingId ) => {
setHiddenRowIds ( prev => {
const next = new Set ( prev ) ;
next . delete ( String ( findingId ) ) ;
saveHiddenRows ( next ) ;
return next ;
} ) ;
} , [ ] ) ;
const restoreAllRows = useCallback ( ( ) => {
setHiddenRowIds ( new Set ( ) ) ;
saveHiddenRows ( new Set ( ) ) ;
} , [ ] ) ;
// Selection state (row visibility feature — bulk hide)
const [ selectedRowIds , setSelectedRowIds ] = useState ( new Set ( ) ) ;
const toggleRowSelection = useCallback ( ( findingId ) => {
setSelectedRowIds ( prev => {
const next = new Set ( prev ) ;
const id = String ( findingId ) ;
if ( next . has ( id ) ) next . delete ( id ) ; else next . add ( id ) ;
return next ;
} ) ;
} , [ ] ) ;
const hideSelectedRows = useCallback ( ( ) => {
setHiddenRowIds ( prev => {
const next = new Set ( prev ) ;
selectedRowIds . forEach ( id => next . add ( String ( id ) ) ) ;
saveHiddenRows ( next ) ;
return next ;
} ) ;
setSelectedRowIds ( new Set ( ) ) ;
} , [ selectedRowIds ] ) ;
2026-04-09 14:42:23 -06:00
// CVE tooltip hover handlers
const handleCveMouseEnter = useCallback ( ( cveId , e ) => {
clearTimeout ( hoverTimerRef . current ) ;
hoverTimerRef . current = setTimeout ( ( ) => {
setTooltipCveId ( cveId ) ;
setTooltipAnchorRect ( e . target . getBoundingClientRect ( ) ) ;
} , 300 ) ;
} , [ ] ) ;
const handleCveMouseLeave = useCallback ( ( ) => {
clearTimeout ( hoverTimerRef . current ) ;
setTooltipCveId ( null ) ;
setTooltipAnchorRect ( null ) ;
} , [ ] ) ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
const applyState = ( data ) => {
setTotal ( data . total ? ? 0 ) ;
setFindings ( data . findings || [ ] ) ;
setSyncedAt ( data . synced _at || null ) ;
setSyncStatus ( data . sync _status || null ) ;
setSyncError ( data . error _message || null ) ;
} ;
2026-03-13 12:23:05 -06:00
const fetchCounts = async ( ) => {
setCountsLoading ( true ) ;
try {
const res = await fetch ( ` ${ API _BASE } /ivanti/findings/counts ` , { credentials : 'include' } ) ;
const data = await res . json ( ) ;
if ( res . ok ) setStatusCounts ( { open : data . open ? ? 0 , closed : data . closed ? ? 0 } ) ;
} catch ( e ) {
console . error ( 'Error loading status counts:' , e ) ;
} finally {
setCountsLoading ( false ) ;
}
} ;
2026-03-16 11:43:57 -06:00
const fetchFPWorkflowCounts = async ( ) => {
try {
const res = await fetch ( ` ${ API _BASE } /ivanti/findings/fp-workflow-counts ` , { credentials : 'include' } ) ;
const data = await res . json ( ) ;
2026-03-16 12:13:13 -06:00
if ( res . ok ) setFPCounts ( {
findingCounts : data . findingCounts || { } ,
findingTotal : data . findingTotal || 0 ,
idCounts : data . idCounts || { } ,
idTotal : data . idTotal || 0 ,
} ) ;
2026-03-16 11:43:57 -06:00
} catch ( e ) {
console . error ( 'Error loading FP workflow counts:' , e ) ;
}
} ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
const fetchFindings = async ( ) => {
setLoading ( true ) ;
try {
2026-03-11 13:03:17 -06:00
const res = await fetch ( ` ${ API _BASE } /ivanti/findings ` , { credentials : 'include' } ) ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
const data = await res . json ( ) ;
2026-04-09 14:42:23 -06:00
if ( res . ok ) {
applyState ( data ) ;
tooltipCacheRef . current . clear ( ) ;
}
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
} catch ( e ) {
console . error ( 'Error loading findings:' , e ) ;
} finally {
setLoading ( false ) ;
}
} ;
const syncFindings = async ( ) => {
setSyncing ( true ) ;
try {
2026-03-11 13:03:17 -06:00
const res = await fetch ( ` ${ API _BASE } /ivanti/findings/sync ` , { method : 'POST' , credentials : 'include' } ) ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
const data = await res . json ( ) ;
2026-03-13 12:23:05 -06:00
if ( res . ok ) {
applyState ( data ) ;
2026-04-09 14:42:23 -06:00
tooltipCacheRef . current . clear ( ) ;
2026-03-16 11:43:57 -06:00
fetchCounts ( ) ; // refresh counts after sync
fetchFPWorkflowCounts ( ) ; // refresh FP workflow counts after sync
2026-03-13 12:23:05 -06:00
}
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
} catch ( e ) {
console . error ( 'Error syncing findings:' , e ) ;
} finally {
setSyncing ( false ) ;
}
} ;
2026-03-13 12:23:05 -06:00
useEffect ( ( ) => {
fetchFindings ( ) ;
fetchCounts ( ) ;
2026-03-16 11:43:57 -06:00
fetchFPWorkflowCounts ( ) ;
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
fetchQueue ( ) ;
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
fetchFpSubmissions ( ) ;
2026-03-13 12:23:05 -06:00
} , [ ] ) ; // eslint-disable-line
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
2026-03-11 13:03:17 -06:00
// Set/clear a single column filter
const setColFilter = useCallback ( ( colKey , vals ) => {
setColumnFilters ( ( prev ) => {
if ( ! vals ) {
const next = { ... prev } ;
delete next [ colKey ] ;
return next ;
}
return { ... prev , [ colKey ] : vals } ;
} ) ;
} , [ ] ) ;
2026-04-15 13:15:01 -06:00
// Visible findings — hidden rows removed before any other filtering
const visibleFindings = useMemo ( ( ) => {
if ( hiddenRowIds . size === 0 ) return findings ;
return findings . filter ( f => ! hiddenRowIds . has ( String ( f . id ) ) ) ;
} , [ findings , hiddenRowIds ] ) ;
2026-03-13 13:06:54 -06:00
// Apply all active filters to produce the visible row set
2026-03-11 13:03:17 -06:00
const filtered = useMemo ( ( ) => {
2026-04-15 13:15:01 -06:00
let result = visibleFindings ;
2026-03-13 13:06:54 -06:00
// Column filters
2026-03-11 13:03:17 -06:00
const active = Object . entries ( columnFilters ) ;
2026-03-13 13:06:54 -06:00
if ( active . length > 0 ) {
result = result . filter ( ( f ) =>
active . every ( ( [ key , vals ] ) => {
if ( ! vals || vals . size === 0 ) return false ;
const def = COLUMN _DEFS [ key ] ;
if ( def ? . multiValue ) {
2026-03-16 13:27:16 -06:00
const arr = f [ key ] || [ ] ;
if ( arr . length === 0 ) return vals . has ( EMPTY _SENTINEL ) ;
return arr . some ( ( v ) => vals . has ( String ( v ) . trim ( ) ) ) ;
2026-03-13 13:06:54 -06:00
}
2026-03-16 13:27:16 -06:00
const fval = getFilterVal ( f , key ) . trim ( ) ;
return fval === '' ? vals . has ( EMPTY _SENTINEL ) : vals . has ( fval ) ;
2026-03-13 13:06:54 -06:00
} )
) ;
}
// Action coverage filter (chart segment click)
if ( actionFilter ) {
result = result . filter ( ( f ) => classifyFinding ( f ) === actionFilter ) ;
}
// EXC filter (navigated from home page Archer ticket)
if ( excFilter ) {
const upper = excFilter . toUpperCase ( ) ;
result = result . filter ( ( f ) => ( f . note || '' ) . toUpperCase ( ) . includes ( upper ) ) ;
}
return result ;
2026-04-15 13:15:01 -06:00
} , [ visibleFindings , columnFilters , actionFilter , excFilter ] ) ;
2026-03-11 13:03:17 -06:00
2026-03-11 12:47:11 -06:00
// Visible columns in current order
const visibleCols = columnOrder . filter ( ( c ) => c . visible && COLUMN _DEFS [ c . key ] ) ;
2026-03-11 13:03:17 -06:00
// Sort filtered results
const sorted = useMemo ( ( ) => [ ... filtered ] . sort ( ( a , b ) => {
2026-03-11 12:47:11 -06:00
const av = getVal ( a , sort . field ) ;
const bv = getVal ( b , sort . field ) ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
let cmp = 0 ;
if ( typeof av === 'number' && typeof bv === 'number' ) {
cmp = av - bv ;
} else {
cmp = String ( av ) . localeCompare ( String ( bv ) , undefined , { numeric : true } ) ;
}
return sort . dir === 'asc' ? cmp : - cmp ;
2026-03-11 13:03:17 -06:00
} ) , [ filtered , sort ] ) ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
2026-04-15 13:15:01 -06:00
// Select/deselect all visible rows
const toggleSelectAll = useCallback ( ( ) => {
const allVisibleIds = sorted . map ( f => String ( f . id ) ) ;
setSelectedRowIds ( prev => {
if ( prev . size === allVisibleIds . length ) return new Set ( ) ; // deselect all
return new Set ( allVisibleIds ) ; // select all
} ) ;
} , [ sorted ] ) ;
// Prune selection to only include IDs present in the current sorted (visible) rows
useEffect ( ( ) => {
setSelectedRowIds ( prev => {
const visibleIds = new Set ( sorted . map ( f => String ( f . id ) ) ) ;
const next = new Set ( [ ... prev ] . filter ( id => visibleIds . has ( id ) ) ) ;
return next . size === prev . size ? prev : next ;
} ) ;
} , [ sorted ] ) ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
const toggleSort = ( key ) => {
setSort ( ( prev ) =>
prev . field === key
? { field : key , dir : prev . dir === 'asc' ? 'desc' : 'asc' }
: { field : key , dir : 'asc' }
) ;
} ;
2026-03-13 13:06:54 -06:00
const activeFilterCount = Object . keys ( columnFilters ) . length + ( actionFilter ? 1 : 0 ) + ( excFilter ? 1 : 0 ) ;
2026-03-11 13:03:17 -06:00
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
// 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' } ) ;
2026-04-07 16:20:24 -06:00
// FP Workflow modal state
const [ fpModalOpen , setFpModalOpen ] = useState ( false ) ;
const [ fpModalItems , setFpModalItems ] = useState ( [ ] ) ;
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
// FP Submission editing state
2026-04-13 12:39:47 -06:00
const [ fpSubmissionsRaw , setFpSubmissions ] = useState ( [ ] ) ;
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
const [ editSubmission , setEditSubmission ] = useState ( null ) ;
2026-04-13 12:39:47 -06:00
// Enrich submissions with actual Ivanti workflow state from findings data
const fpSubmissions = useMemo ( ( ) => {
if ( fpSubmissionsRaw . length === 0 || findings . length === 0 ) return fpSubmissionsRaw ;
const stateMap = {
'reworked' : 'rework' , 'rejected' : 'rejected' , 'expired' : 'rejected' ,
'approved' : 'approved' , 'requested' : 'submitted' , 'actionable' : 'submitted' ,
} ;
return fpSubmissionsRaw . map ( sub => {
let findingIds ;
try { findingIds = JSON . parse ( sub . finding _ids _json || '[]' ) ; } catch { return sub ; }
if ( findingIds . length === 0 ) return sub ;
const matchedFinding = findings . find ( f =>
f . workflow && findingIds . includes ( String ( f . id ) )
) ;
if ( ! matchedFinding || ! matchedFinding . workflow ) return sub ;
const ivantiState = ( matchedFinding . workflow . state || '' ) . toLowerCase ( ) ;
const mappedStatus = stateMap [ ivantiState ] ;
if ( mappedStatus && mappedStatus !== sub . lifecycle _status ) {
return { ... sub , lifecycle _status : mappedStatus } ;
}
return sub ;
} ) ;
} , [ fpSubmissionsRaw , findings ] ) ;
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
// 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 ) ;
}
} , [ ] ) ;
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
const fetchFpSubmissions = useCallback ( async ( ) => {
try {
const res = await fetch ( ` ${ API _BASE } /ivanti/fp-workflow/submissions ` , { credentials : 'include' } ) ;
const data = await res . json ( ) ;
if ( res . ok ) setFpSubmissions ( data ) ;
} catch ( e ) {
console . error ( 'Error fetching FP submissions:' , e ) ;
}
} , [ ] ) ;
2026-04-07 16:20:24 -06:00
// FP Workflow handlers
const handleCreateFpWorkflow = useCallback ( ( selectedIds ) => {
const selectedSet = new Set ( selectedIds ) ;
const fpItems = filterFpItems (
queueItems . filter ( item => selectedSet . has ( item . id ) && item . status === 'pending' )
) ;
if ( fpItems . length > 0 ) {
setFpModalItems ( fpItems ) ;
setFpModalOpen ( true ) ;
}
} , [ queueItems ] ) ;
const handleFpWorkflowSuccess = useCallback ( ( ) => {
fetchQueue ( ) ;
} , [ fetchQueue ] ) ;
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
const handleEditSubmission = useCallback ( ( submission ) => {
setEditSubmission ( submission ) ;
} , [ ] ) ;
const handleEditSuccess = useCallback ( ( ) => {
fetchFpSubmissions ( ) ;
fetchQueue ( ) ;
fetchFindings ( ) ;
} , [ fetchFpSubmissions , fetchQueue ] ) ; // eslint-disable-line
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
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 ,
2026-03-26 15:01:32 -06:00
finding _title : finding . title || null ,
cves : finding . cves || [ ] ,
ip _address : finding . ipAddress || null ,
2026-04-09 15:25:16 -06:00
hostname : finding . overrides ? . hostName || finding . hostName || null ,
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
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 ] ) ;
2026-04-09 09:56:33 -06:00
// Prune selection when filters change — keep only IDs still in filtered set
useEffect ( ( ) => {
setSelectedIds ( ( prev ) => {
if ( prev . size === 0 ) return prev ;
const visibleIds = new Set ( filtered . map ( ( f ) => f . id ) ) ;
const next = new Set ( [ ... prev ] . filter ( ( id ) => visibleIds . has ( id ) ) ) ;
return next . size === prev . size ? prev : next ;
} ) ;
} , [ filtered ] ) ;
// Escape key clears selection
useEffect ( ( ) => {
if ( selectedIds . size === 0 ) return ;
const handler = ( e ) => {
if ( e . key === 'Escape' && selectedIds . size > 0 && ! addPopover ) {
setSelectedIds ( new Set ( ) ) ;
setBatchError ( null ) ;
}
} ;
document . addEventListener ( 'keydown' , handler ) ;
return ( ) => document . removeEventListener ( 'keydown' , handler ) ;
} , [ selectedIds , addPopover ] ) ;
2026-04-09 09:49:40 -06:00
const submitBatch = useCallback ( async ( ) => {
if ( selectedIds . size === 0 ) return ;
setBatchSubmitting ( true ) ;
setBatchError ( null ) ;
try {
const findingsPayload = [ ... selectedIds ] . map ( ( id ) => {
const f = findings . find ( ( ff ) => ff . id === id ) ;
return f ? {
finding _id : f . id ,
finding _title : f . title || null ,
cves : f . cves || [ ] ,
ip _address : f . ipAddress || null ,
2026-04-09 15:25:16 -06:00
hostname : f . overrides ? . hostName || f . hostName || null ,
2026-04-09 09:49:40 -06:00
} : { finding _id : id } ;
} ) ;
const res = await fetch ( ` ${ API _BASE } /ivanti/todo-queue/batch ` , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
findings : findingsPayload ,
workflow _type : batchWorkflowType ,
vendor : batchWorkflowType === 'CARD' ? '' : batchVendor . trim ( ) ,
} ) ,
} ) ;
const data = await res . json ( ) ;
if ( res . ok ) {
setQueueItems ( ( prev ) => [ ... prev , ... ( data . items || [ ] ) ] . sort ( ( a , b ) =>
( a . vendor || '' ) . localeCompare ( b . vendor || '' ) || a . id - b . id
) ) ;
setSelectedIds ( new Set ( ) ) ;
setBatchWorkflowType ( 'FP' ) ;
setBatchVendor ( '' ) ;
setBatchError ( null ) ;
} else {
setBatchError ( data . error || 'Failed to add findings to queue.' ) ;
}
} catch ( e ) {
console . error ( 'Error in batch add:' , e ) ;
setBatchError ( 'Network error — please try again.' ) ;
} finally {
setBatchSubmitting ( false ) ;
}
} , [ selectedIds , findings , batchWorkflowType , batchVendor ] ) ;
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
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 ) ;
}
} , [ ] ) ;
2026-03-26 15:43:43 -06:00
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 ) ;
}
} , [ ] ) ;
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
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 ;
2026-03-13 12:08:20 -06:00
const [ exportMenuOpen , setExportMenuOpen ] = useState ( false ) ;
const exportBtnRef = useRef ( null ) ;
// Close export menu on outside click
useEffect ( ( ) => {
if ( ! exportMenuOpen ) return ;
const handler = ( e ) => {
if ( exportBtnRef . current && ! exportBtnRef . current . contains ( e . target ) ) {
setExportMenuOpen ( false ) ;
}
} ;
document . addEventListener ( 'mousedown' , handler ) ;
return ( ) => document . removeEventListener ( 'mousedown' , handler ) ;
} , [ exportMenuOpen ] ) ;
const buildExportRows = useCallback ( ( ) => {
const cols = visibleCols . filter ( ( c ) => COLUMN _DEFS [ c . key ] ) ;
const headers = cols . map ( ( c ) => COLUMN _DEFS [ c . key ] . label ) ;
const rows = sorted . map ( ( finding ) =>
cols . map ( ( c ) => getExportVal ( finding , c . key ) )
) ;
return [ headers , ... rows ] ;
} , [ sorted , visibleCols ] ) ;
const exportCSV = useCallback ( ( ) => {
setExportMenuOpen ( false ) ;
const rows = buildExportRows ( ) ;
const csvContent = rows . map ( ( row ) =>
row . map ( ( cell ) => {
const s = String ( cell ? ? '' ) ;
// Quote if it contains comma, double-quote, or newline
if ( s . includes ( ',' ) || s . includes ( '"' ) || s . includes ( '\n' ) ) {
return ` " ${ s . replace ( /"/g , '""' ) } " ` ;
}
return s ;
} ) . join ( ',' )
) . join ( '\r\n' ) ;
const blob = new Blob ( [ '\uFEFF' + csvContent ] , { type : 'text/csv;charset=utf-8;' } ) ;
const url = URL . createObjectURL ( blob ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ;
a . download = ` findings-export- ${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } .csv ` ;
document . body . appendChild ( a ) ;
a . click ( ) ;
document . body . removeChild ( a ) ;
URL . revokeObjectURL ( url ) ;
} , [ buildExportRows ] ) ;
const exportXLSX = useCallback ( ( ) => {
setExportMenuOpen ( false ) ;
const rows = buildExportRows ( ) ;
const ws = XLSX . utils . aoa _to _sheet ( rows ) ;
// Auto-fit column widths
const colWidths = rows [ 0 ] . map ( ( _ , ci ) =>
Math . min ( 60 , Math . max ( 10 , ... rows . map ( ( r ) => String ( r [ ci ] ? ? '' ) . length ) ) )
) ;
ws [ '!cols' ] = colWidths . map ( ( w ) => ( { wch : w } ) ) ;
const wb = XLSX . utils . book _new ( ) ;
XLSX . utils . book _append _sheet ( wb , ws , 'Findings' ) ;
XLSX . writeFile ( wb , ` findings-export- ${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } .xlsx ` ) ;
} , [ buildExportRows ] ) ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
const syncedDisplay = syncedAt
2026-03-11 12:47:11 -06:00
? ` Synced ${ new Date ( syncedAt . replace ( ' ' , 'T' ) + 'Z' ) . toLocaleString ( ) } `
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
: 'Never synced' ;
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
< div style = { { display : 'flex' , flexDirection : 'column' , gap : '1.5rem' } } >
{ / * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
2026-03-11 12:47:11 -06:00
Panel 1 — Metrics placeholder
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- * / }
< div style = { {
background : 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)' ,
border : '1px solid rgba(245,158,11,0.2)' ,
borderLeft : '3px solid #F59E0B' ,
borderRadius : '0.5rem' ,
padding : '1.5rem' ,
boxShadow : '0 4px 16px rgba(0,0,0,0.4)'
} } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : '0.625rem' , marginBottom : '1rem' } } >
< PieChart style = { { width : '20px' , height : '20px' , color : '#F59E0B' } } / >
< h2 style = { { fontFamily : 'monospace' , fontSize : '1rem' , fontWeight : '600' , color : '#F59E0B' , textTransform : 'uppercase' , letterSpacing : '0.1em' , textShadow : '0 0 12px rgba(245,158,11,0.4)' , margin : 0 } } >
Metric Graphs
< / h 2 >
< / d i v >
2026-03-13 12:50:15 -06:00
< div style = { { display : 'flex' , gap : '3rem' , flexWrap : 'wrap' , alignItems : 'flex-start' } } >
2026-03-13 12:23:05 -06:00
{ /* Open vs Closed donut */ }
< div style = { { flex : '0 0 auto' } } >
< div style = { { fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' , color : '#64748B' , textTransform : 'uppercase' , letterSpacing : '0.1em' , marginBottom : '0.75rem' } } >
Open vs Closed
< / d i v >
< StatusDonut
open = { statusCounts . open }
closed = { statusCounts . closed }
loading = { countsLoading }
/ >
< / d i v >
2026-03-13 12:50:15 -06:00
{ /* Divider */ }
< div style = { { width : '1px' , background : 'rgba(255,255,255,0.06)' , alignSelf : 'stretch' , flexShrink : 0 } } / >
2026-03-13 13:06:54 -06:00
{ /* Action Coverage donut */ }
2026-03-13 12:50:15 -06:00
< div style = { { flex : '0 0 auto' } } >
< div style = { { fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' , color : '#64748B' , textTransform : 'uppercase' , letterSpacing : '0.1em' , marginBottom : '0.75rem' } } >
2026-03-13 13:06:54 -06:00
Action Coverage
{ actionFilter && < span style = { { marginLeft : '0.5rem' , color : ACTION _DEFS . find ( d => d . key === actionFilter ) ? . color , fontSize : '0.6rem' } } > ● filtered < / s p a n > }
2026-03-13 12:50:15 -06:00
< / d i v >
2026-03-13 13:06:54 -06:00
< ActionCoverageDonut
2026-04-15 13:15:01 -06:00
findings = { visibleFindings }
2026-03-13 13:06:54 -06:00
activeSegment = { actionFilter }
onSegmentClick = { ( key ) => {
setExcFilter ( null ) ;
setActionFilter ( key ) ;
} }
/ >
2026-03-13 12:50:15 -06:00
< / d i v >
2026-03-16 11:16:01 -06:00
{ /* Divider */ }
< div style = { { width : '1px' , background : 'rgba(255,255,255,0.06)' , alignSelf : 'stretch' , flexShrink : 0 } } / >
2026-03-16 12:13:13 -06:00
{ /* FP Finding Status donut — # of findings per FP workflow state */ }
< div style = { { flex : '0 0 auto' } } >
< div style = { { fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' , color : '#64748B' , textTransform : 'uppercase' , letterSpacing : '0.1em' , marginBottom : '0.75rem' } } >
FP Finding Status
< / d i v >
< FPWorkflowDonut counts = { fpCounts . findingCounts } total = { fpCounts . findingTotal } centerLabel = "FINDINGS" / >
< / d i v >
{ /* Divider */ }
< div style = { { width : '1px' , background : 'rgba(255,255,255,0.06)' , alignSelf : 'stretch' , flexShrink : 0 } } / >
{ /* FP Workflow Status donut — # of unique FP# ticket IDs per state */ }
2026-03-16 11:16:01 -06:00
< div style = { { flex : '0 0 auto' } } >
< div style = { { fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' , color : '#64748B' , textTransform : 'uppercase' , letterSpacing : '0.1em' , marginBottom : '0.75rem' } } >
FP Workflow Status
< / d i v >
2026-03-16 12:13:13 -06:00
< FPWorkflowDonut counts = { fpCounts . idCounts } total = { fpCounts . idTotal } centerLabel = "FP TICKETS" / >
2026-03-16 11:16:01 -06:00
< / d i v >
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
< / d i v >
< / d i v >
2026-04-02 10:12:04 -06:00
{ / * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Panel 1.5 — Open vs Closed trend over time
-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- * / }
< IvantiCountsChart / >
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
{ / * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Panel 2 — Findings table
-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- * / }
< div style = { {
background : 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)' ,
border : '1px solid rgba(14,165,233,0.2)' ,
borderLeft : '3px solid #0EA5E9' ,
borderRadius : '0.5rem' ,
padding : '1.5rem' ,
boxShadow : '0 4px 16px rgba(0,0,0,0.4)'
} } >
2026-03-11 12:47:11 -06:00
{ /* Panel header */ }
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'flex-start' , marginBottom : '0.5rem' } } >
< div >
< h2 style = { { fontFamily : 'monospace' , fontSize : '1rem' , fontWeight : '600' , color : '#0EA5E9' , textTransform : 'uppercase' , letterSpacing : '0.1em' , textShadow : '0 0 12px rgba(14,165,233,0.4)' , margin : '0 0 4px 0' } } >
Host Findings
< / h 2 >
< div style = { { fontFamily : 'monospace' , fontSize : '0.7rem' , color : '#475569' } } >
{ syncedDisplay }
{ syncStatus === 'success' && total !== null && (
2026-03-11 13:03:17 -06:00
< span style = { { marginLeft : '0.75rem' , color : '#64748B' } } >
{ activeFilterCount > 0 ? ` ${ filtered . length } of ${ total } ` : total } findings
{ activeFilterCount > 0 && (
< span style = { { marginLeft : '0.5rem' , color : '#F59E0B' } } >
( { activeFilterCount } filter { activeFilterCount > 1 ? 's' : '' } active )
< / s p a n >
) }
< / s p a n >
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
) }
< / d i v >
< / d i v >
2026-03-11 12:47:11 -06:00
{ /* Action buttons */ }
< div style = { { display : 'flex' , gap : '0.5rem' , alignItems : 'center' } } >
2026-03-13 13:06:54 -06:00
{ /* EXC filter badge (from home page navigation) */ }
{ excFilter && (
< button
onClick = { ( ) => setExcFilter ( null ) }
style = { {
display : 'flex' , alignItems : 'center' , gap : '0.375rem' ,
padding : '0.375rem 0.75rem' ,
background : 'rgba(245,158,11,0.08)' ,
border : '1px solid rgba(245,158,11,0.3)' ,
borderRadius : '0.375rem' ,
color : '#F59E0B' , cursor : 'pointer' ,
fontFamily : 'monospace' , fontSize : '0.75rem' , fontWeight : '600' ,
letterSpacing : '0.05em'
} }
>
< Filter style = { { width : '11px' , height : '11px' } } / >
{ excFilter } ×
< / b u t t o n >
) }
{ /* Action coverage filter badge (from chart click) */ }
{ actionFilter && (
< button
onClick = { ( ) => setActionFilter ( null ) }
style = { {
display : 'flex' , alignItems : 'center' , gap : '0.375rem' ,
padding : '0.375rem 0.75rem' ,
background : actionFilter === 'fp' ? 'rgba(14,165,233,0.08)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.08)' : 'rgba(239,68,68,0.08)' ,
border : ` 1px solid ${ actionFilter === 'fp' ? 'rgba(14,165,233,0.3)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.3)' : 'rgba(239,68,68,0.3)' } ` ,
borderRadius : '0.375rem' ,
color : actionFilter === 'fp' ? '#0EA5E9' : actionFilter === 'archer' ? '#F59E0B' : '#EF4444' ,
cursor : 'pointer' ,
fontFamily : 'monospace' , fontSize : '0.75rem' , fontWeight : '600' ,
letterSpacing : '0.05em'
} }
>
< Filter style = { { width : '11px' , height : '11px' } } / >
{ ACTION _DEFS . find ( d => d . key === actionFilter ) ? . label } ×
< / b u t t o n >
) }
{ Object . keys ( columnFilters ) . length > 0 && (
2026-03-11 13:03:17 -06:00
< button
onClick = { ( ) => setColumnFilters ( { } ) }
style = { {
display : 'flex' , alignItems : 'center' , gap : '0.375rem' ,
padding : '0.375rem 0.75rem' ,
background : 'rgba(245,158,11,0.08)' ,
border : '1px solid rgba(245,158,11,0.3)' ,
borderRadius : '0.375rem' ,
color : '#F59E0B' , cursor : 'pointer' ,
fontFamily : 'monospace' , fontSize : '0.75rem' , fontWeight : '600' ,
textTransform : 'uppercase' , letterSpacing : '0.05em'
} }
>
< Filter style = { { width : '11px' , height : '11px' } } / >
Clear Filters
< / b u t t o n >
) }
2026-03-13 12:08:20 -06:00
{ /* Export dropdown */ }
< div ref = { exportBtnRef } style = { { position : 'relative' } } >
< button
onClick = { ( ) => setExportMenuOpen ( ( o ) => ! o ) }
disabled = { sorted . length === 0 }
style = { {
display : 'flex' , alignItems : 'center' , gap : '0.375rem' ,
padding : '0.375rem 0.75rem' ,
background : 'rgba(16,185,129,0.08)' ,
border : '1px solid rgba(16,185,129,0.3)' ,
borderRadius : '0.375rem' ,
color : '#10B981' , cursor : sorted . length === 0 ? 'not-allowed' : 'pointer' ,
fontFamily : 'monospace' , fontSize : '0.75rem' , fontWeight : '600' ,
textTransform : 'uppercase' , letterSpacing : '0.05em' ,
opacity : sorted . length === 0 ? 0.4 : 1 ,
} }
>
< Download style = { { width : '11px' , height : '11px' } } / >
Export
< ChevronDown style = { { width : '10px' , height : '10px' , marginLeft : '1px' } } / >
< / b u t t o n >
{ exportMenuOpen && (
< div style = { {
position : 'absolute' , top : 'calc(100% + 4px)' , right : 0 , zIndex : 200 ,
background : 'rgb(12,22,40)' , border : '1px solid rgba(16,185,129,0.3)' ,
borderRadius : '0.375rem' , overflow : 'hidden' ,
boxShadow : '0 4px 16px rgba(0,0,0,0.5)' ,
minWidth : '120px' ,
} } >
{ [
{ label : 'CSV (.csv)' , action : exportCSV } ,
{ label : 'Excel (.xlsx)' , action : exportXLSX } ,
] . map ( ( { label , action } ) => (
< button
key = { label }
onClick = { action }
style = { {
display : 'block' , width : '100%' , textAlign : 'left' ,
padding : '0.5rem 0.875rem' ,
background : 'none' , border : 'none' ,
fontFamily : 'monospace' , fontSize : '0.73rem' , fontWeight : '600' ,
color : '#10B981' , cursor : 'pointer' ,
textTransform : 'uppercase' , letterSpacing : '0.04em' ,
transition : 'background 0.1s' ,
} }
onMouseEnter = { ( e ) => e . currentTarget . style . background = 'rgba(16,185,129,0.1)' }
onMouseLeave = { ( e ) => e . currentTarget . style . background = 'none' }
>
{ label }
< / b u t t o n >
) ) }
< / d i v >
) }
< / d i v >
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
{ /* 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 }
< / s p a n >
) }
< / b u t t o n >
2026-03-11 12:47:11 -06:00
< ColumnManager columnOrder = { columnOrder } onChange = { updateColumns } / >
2026-04-15 13:15:01 -06:00
< RowVisibilityManager hiddenRowIds = { hiddenRowIds } findings = { findings } onRestore = { restoreRow } onRestoreAll = { restoreAllRows } / >
2026-03-11 12:47:11 -06:00
< button
onClick = { syncFindings }
disabled = { syncing || loading }
style = { {
display : 'flex' , alignItems : 'center' , gap : '0.375rem' ,
padding : '0.375rem 0.75rem' ,
background : 'rgba(14,165,233,0.1)' ,
border : '1px solid rgba(14,165,233,0.35)' ,
borderRadius : '0.375rem' ,
color : '#0EA5E9' , cursor : 'pointer' ,
fontFamily : 'monospace' , fontSize : '0.75rem' , fontWeight : '600' ,
textTransform : 'uppercase' , letterSpacing : '0.05em' ,
opacity : ( syncing || loading ) ? 0.6 : 1
} }
>
< RefreshCw style = { { width : '13px' , height : '13px' , animation : syncing ? 'spin 1s linear infinite' : 'none' } } / >
{ syncing ? 'Syncing…' : 'Sync' }
< / b u t t o n >
< / d i v >
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
< / d i v >
{ /* Error banner */ }
{ syncStatus === 'error' && syncError && (
< div style = { { display : 'flex' , alignItems : 'flex-start' , gap : '0.5rem' , padding : '0.625rem 0.875rem' , background : 'rgba(239,68,68,0.08)' , border : '1px solid rgba(239,68,68,0.25)' , borderRadius : '0.375rem' , marginBottom : '1rem' } } >
< AlertCircle style = { { width : '15px' , height : '15px' , color : '#EF4444' , flexShrink : 0 , marginTop : '1px' } } / >
< span style = { { fontSize : '0.75rem' , color : '#FCA5A5' , fontFamily : 'monospace' } } > { syncError } < / s p a n >
< / d i v >
) }
2026-03-11 12:47:11 -06:00
{ /* Content */ }
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
{ loading ? (
< div style = { { textAlign : 'center' , padding : '3rem 0' } } >
< Loader style = { { width : '28px' , height : '28px' , color : '#0EA5E9' , animation : 'spin 1s linear infinite' , margin : '0 auto 0.75rem' } } / >
< p style = { { fontFamily : 'monospace' , fontSize : '0.75rem' , color : '#475569' } } > Loading findings … < / p >
< / d i v >
) : syncStatus === 'never' ? (
< div style = { { textAlign : 'center' , padding : '3rem 0' } } >
< p style = { { fontFamily : 'monospace' , fontSize : '0.75rem' , color : '#475569' } } > Click Sync to load findings data < / p >
< / d i v >
) : (
2026-03-11 13:23:56 -06:00
< div style = { { overflowX : 'auto' , overflowY : 'auto' , maxHeight : 'calc(100vh - 420px)' , minHeight : '200px' , marginTop : '0.75rem' } } >
2026-04-09 09:49:40 -06:00
{ selectedIds . size > 0 && canWrite ( ) && (
< SelectionToolbar
count = { selectedIds . size }
workflowType = { batchWorkflowType }
vendor = { batchVendor }
submitting = { batchSubmitting }
error = { batchError }
onWorkflowChange = { setBatchWorkflowType }
onVendorChange = { setBatchVendor }
onSubmit = { submitBatch }
onClear = { ( ) => { setSelectedIds ( new Set ( ) ) ; setBatchError ( null ) ; } }
/ >
) }
2026-04-15 13:15:01 -06:00
{ selectedRowIds . size > 0 && (
< BulkHideToolbar
count = { selectedRowIds . size }
onHide = { hideSelectedRows }
onClear = { ( ) => setSelectedRowIds ( new Set ( ) ) }
/ >
) }
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
< table style = { { width : '100%' , borderCollapse : 'collapse' , fontSize : '0.75rem' , fontFamily : 'sans-serif' } } >
< thead >
< tr style = { { borderBottom : '1px solid rgba(14,165,233,0.2)' } } >
2026-04-15 13:15:01 -06:00
{ /* Fixed selection checkbox column — row visibility feature */ }
< 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)' ,
textAlign : 'center' ,
cursor : 'pointer' ,
} }
onClick = { toggleSelectAll }
>
{ ( ( ) => {
const allVisibleIds = sorted . map ( f => String ( f . id ) ) ;
const selectedCount = allVisibleIds . filter ( id => selectedRowIds . has ( id ) ) . length ;
const allSelected = allVisibleIds . length > 0 && selectedCount === allVisibleIds . length ;
const someSelected = selectedCount > 0 && ! allSelected ;
if ( allSelected ) return < CheckSquare style = { { width : '13px' , height : '13px' , color : '#4fc3f7' } } / > ;
if ( someSelected ) return < MinusSquare style = { { width : '13px' , height : '13px' , color : '#4fc3f7' } } / > ;
return < Square style = { { width : '13px' , height : '13px' , color : '#8892a2' } } / > ;
} ) ( ) }
< / t h >
{ /* Fixed hide button column — row visibility feature */ }
< 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)' ,
} }
/ >
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
{ /* 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)' ,
2026-04-09 09:49:40 -06:00
textAlign : 'center' ,
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
} }
2026-04-09 09:49:40 -06:00
>
{ canWrite ( ) && (
< input
type = "checkbox"
checked = { sorted . length > 0 && sorted . filter ( ( f ) => ! isQueued ( f . id ) ) . length > 0 && sorted . filter ( ( f ) => ! isQueued ( f . id ) ) . every ( ( f ) => selectedIds . has ( f . id ) ) }
onChange = { ( ) => {
const nonQueued = sorted . filter ( ( f ) => ! isQueued ( f . id ) ) ;
const allSelected = nonQueued . length > 0 && nonQueued . every ( ( f ) => selectedIds . has ( f . id ) ) ;
if ( allSelected ) {
setSelectedIds ( new Set ( ) ) ;
} else {
setSelectedIds ( new Set ( nonQueued . map ( ( f ) => f . id ) ) ) ;
}
} }
style = { {
accentColor : '#0EA5E9' ,
width : '13px' , height : '13px' ,
cursor : 'pointer' ,
} }
title = "Select all visible findings"
/ >
) }
< / t h >
2026-03-11 12:47:11 -06:00
{ visibleCols . map ( ( col ) => {
2026-03-11 13:03:17 -06:00
const def = COLUMN _DEFS [ col . key ] ;
const active = sort . field === col . key ;
const isFiltered = ! ! columnFilters [ col . key ] ;
2026-03-11 12:47:11 -06:00
return (
< th
key = { col . key }
onClick = { def ? . sortable ? ( ) => toggleSort ( col . key ) : undefined }
style = { {
2026-03-11 13:03:17 -06:00
padding : '0.5rem 0.75rem' , textAlign : 'left' ,
2026-03-11 12:47:11 -06:00
fontFamily : 'monospace' , fontSize : '0.68rem' , fontWeight : '600' ,
color : active ? '#0EA5E9' : '#64748B' ,
textTransform : 'uppercase' , letterSpacing : '0.08em' ,
whiteSpace : 'nowrap' ,
cursor : def ? . sortable ? 'pointer' : 'default' ,
userSelect : 'none' ,
2026-03-11 13:23:56 -06:00
background : 'rgb(10, 20, 36)' ,
position : 'sticky' , top : 0 , zIndex : 10 ,
boxShadow : '0 1px 0 rgba(14,165,233,0.2)' ,
2026-03-11 12:47:11 -06:00
} }
>
< span style = { { display : 'inline-flex' , alignItems : 'center' } } >
{ def ? . label || col . key }
{ def ? . sortable && < SortIcon colKey = { col . key } sort = { sort } / > }
2026-03-11 13:03:17 -06:00
{ def ? . filterable && (
< button
ref = { ( el ) => { filterBtnRefs . current [ col . key ] = el ; } }
onClick = { ( e ) => {
e . stopPropagation ( ) ;
setOpenFilter ( openFilter === col . key ? null : col . key ) ;
} }
title = { ` Filter ${ def . label } ` }
style = { {
background : 'none' , border : 'none' ,
cursor : 'pointer' , padding : '1px 1px 1px 3px' ,
color : isFiltered ? '#F59E0B' : '#334155' ,
lineHeight : 1 , flexShrink : 0 ,
transition : 'color 0.15s' ,
} }
>
< Filter style = { { width : '10px' , height : '10px' } } / >
< / b u t t o n >
) }
2026-03-11 12:47:11 -06:00
< / s p a n >
< / t h >
) ;
} ) }
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
< / t r >
< / t h e a d >
< tbody >
{ sorted . map ( ( finding , idx ) => {
2026-04-09 09:49:40 -06:00
const isSelected = selectedIds . has ( finding . id ) ;
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : ( idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)' ) ;
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
const queued = isQueued ( finding . id ) ;
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
return (
< tr
key = { finding . id }
style = { { borderBottom : '1px solid rgba(255,255,255,0.04)' , background : rowBg } }
2026-04-09 09:49:40 -06:00
onMouseEnter = { ( e ) => { if ( ! isSelected ) e . currentTarget . style . background = 'rgba(14,165,233,0.05)' ; } }
onMouseLeave = { ( e ) => { e . currentTarget . style . background = rowBg ; } }
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
>
2026-04-15 13:15:01 -06:00
{ /* Selection checkbox cell — row visibility feature */ }
< td
style = { { padding : '0.45rem 0.5rem' , textAlign : 'center' , width : '36px' , cursor : 'pointer' } }
onClick = { ( ) => toggleRowSelection ( finding . id ) }
>
{ selectedRowIds . has ( String ( finding . id ) )
? < CheckSquare style = { { width : '13px' , height : '13px' , color : '#4fc3f7' } } / >
: < Square style = { { width : '13px' , height : '13px' , color : '#8892a2' } } / >
}
< / t d >
{ /* Hide button cell — row visibility feature */ }
< td
style = { { padding : '0.45rem 0.5rem' , textAlign : 'center' , width : '36px' } }
>
< button
onClick = { ( ) => hideRow ( finding . id ) }
title = "Hide this row"
style = { {
background : 'none' , border : 'none' , padding : 0 ,
cursor : 'pointer' , display : 'inline-flex' , alignItems : 'center' , justifyContent : 'center' ,
} }
onMouseEnter = { ( e ) => { e . currentTarget . querySelector ( 'svg' ) . style . color = '#4fc3f7' ; } }
onMouseLeave = { ( e ) => { e . currentTarget . querySelector ( 'svg' ) . style . color = '#8892a2' ; } }
>
< EyeOff style = { { width : '13px' , height : '13px' , color : '#8892a2' } } / >
< / b u t t o n >
< / t d >
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
{ /* Checkbox cell */ }
< td
style = { { padding : '0.45rem 0.5rem' , textAlign : 'center' , width : '36px' } }
onClick = { ( e ) => {
if ( queued ) return ;
2026-04-09 09:49:40 -06:00
// Shift-click range select
if ( e . shiftKey && lastClickedId ) {
const lastIdx = sorted . findIndex ( ( f ) => f . id === lastClickedId ) ;
const currIdx = sorted . findIndex ( ( f ) => f . id === finding . id ) ;
if ( lastIdx !== - 1 && currIdx !== - 1 ) {
const start = Math . min ( lastIdx , currIdx ) ;
const end = Math . max ( lastIdx , currIdx ) ;
setSelectedIds ( ( prev ) => {
const next = new Set ( prev ) ;
for ( let i = start ; i <= end ; i ++ ) {
if ( ! isQueued ( sorted [ i ] . id ) ) next . add ( sorted [ i ] . id ) ;
}
return next ;
} ) ;
}
} else {
2026-04-09 10:01:18 -06:00
// Regular click — toggle selection
2026-04-09 09:49:40 -06:00
setSelectedIds ( ( prev ) => {
const next = new Set ( prev ) ;
if ( next . has ( finding . id ) ) next . delete ( finding . id ) ; else next . add ( finding . id ) ;
return next ;
} ) ;
}
setLastClickedId ( finding . id ) ;
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
} }
>
< input
type = "checkbox"
readOnly
2026-04-09 09:49:40 -06:00
checked = { queued || isSelected }
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
style = { {
2026-04-09 09:49:40 -06:00
accentColor : queued ? '#10B981' : '#0EA5E9' ,
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
width : '13px' , height : '13px' ,
cursor : queued ? 'default' : 'pointer' ,
pointerEvents : 'none' ,
} }
/ >
< / t d >
2026-03-11 12:47:11 -06:00
{ visibleCols . map ( ( col ) => (
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
< TableCell key = { col . key } colKey = { col . key } finding = { finding } canWrite = { canWrite ( ) } onCveMouseEnter = { handleCveMouseEnter } onCveMouseLeave = { handleCveMouseLeave } fpSubmissions = { fpSubmissions } onEditSubmission = { handleEditSubmission } / >
2026-03-11 12:47:11 -06:00
) ) }
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
< / t r >
) ;
} ) }
{ sorted . length === 0 && (
< tr >
2026-04-15 13:15:01 -06:00
< td colSpan = { visibleCols . length + 3 } style = { { textAlign : 'center' , padding : '2rem' , color : '#475569' , fontFamily : 'monospace' , fontSize : '0.75rem' } } >
2026-03-11 13:03:17 -06:00
{ activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found' }
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
< / t d >
< / t r >
) }
< / t b o d y >
< / t a b l e >
< / d i v >
) }
< / d i v >
2026-03-11 13:03:17 -06:00
{ /* Filter dropdown — rendered via portal at document.body */ }
{ openFilter && COLUMN _DEFS [ openFilter ] ? . filterable && (
< FilterDropdown
anchorEl = { filterBtnRefs . current [ openFilter ] }
colKey = { openFilter }
2026-04-15 13:15:01 -06:00
findings = { visibleFindings }
2026-03-11 13:03:17 -06:00
activeFilter = { columnFilters [ openFilter ] || null }
onFilterChange = { ( vals ) => setColFilter ( openFilter , vals ) }
onClose = { ( ) => setOpenFilter ( null ) }
/ >
) }
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
{ /* 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 }
2026-03-26 15:43:43 -06:00
onDeleteMany = { deleteQueueItems }
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
onClearCompleted = { clearCompleted }
2026-04-07 16:20:24 -06:00
onCreateFpWorkflow = { handleCreateFpWorkflow }
2026-04-09 16:01:36 -06:00
onRedirectComplete = { ( newItem ) => {
setQueueItems ( ( prev ) => [ ... prev , newItem ] . sort ( ( a , b ) =>
( a . vendor || '' ) . localeCompare ( b . vendor || '' ) || a . id - b . id
) ) ;
} }
2026-04-07 16:20:24 -06:00
canWrite = { canWrite }
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
fpSubmissions = { fpSubmissions }
onEditSubmission = { handleEditSubmission }
2026-04-07 16:20:24 -06:00
/ >
< FpWorkflowModal
open = { fpModalOpen }
onClose = { ( ) => setFpModalOpen ( false ) }
selectedItems = { fpModalItems }
onSuccess = { handleFpWorkflowSuccess }
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
/ >
feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal
- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
2026-04-13 12:27:56 -06:00
< FpEditModal
open = { ! ! editSubmission }
onClose = { ( ) => setEditSubmission ( null ) }
submission = { editSubmission }
queueItems = { queueItems }
onSuccess = { handleEditSuccess }
/ >
2026-04-09 14:42:23 -06:00
< CveTooltip
cveId = { tooltipCveId }
anchorRect = { tooltipAnchorRect }
cache = { tooltipCacheRef }
/ >
2026-03-11 11:47:03 -06:00
< / d i v >
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
) ;
2026-03-11 11:47:03 -06:00
}