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-24 20:34:34 +00:00
import AnomalyBanner from './AnomalyBanner' ;
2026-04-09 14:42:23 -06:00
import CveTooltip from '../CveTooltip' ;
2026-04-09 16:01:36 -06:00
import RedirectModal from '../RedirectModal' ;
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
import AtlasBadge from '../AtlasBadge' ;
import AtlasSlideOutPanel from '../AtlasSlideOutPanel' ;
2026-04-23 22:18:23 +00:00
import AtlasIcon from '../AtlasIcon' ;
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 ? ? '' ;
2026-04-24 21:38:43 +00:00
case 'hostName' : return finding . overrides ? . hostName || finding . hostName || '' ;
2026-03-11 12:47:11 -06:00
case 'ipAddress' : return finding . ipAddress ? ? '' ;
2026-04-24 21:38:43 +00:00
case 'dns' : return finding . overrides ? . dns || finding . dns || '' ;
2026-03-11 12:47:11 -06:00
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 ( ', ' ) ;
2026-04-24 21:38:43 +00:00
case 'hostName' : return finding . overrides ? . hostName || finding . hostName || '' ;
2026-03-13 12:08:20 -06:00
case 'ipAddress' : return finding . ipAddress ? ? '' ;
2026-04-24 21:38:43 +00:00
case 'dns' : return finding . overrides ? . dns || finding . dns || '' ;
2026-03-13 12:08:20 -06:00
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 >
) ;
}
2026-04-24 17:30:06 +00:00
// ---------------------------------------------------------------------------
// Atlas Donut Charts — Coverage, Plan Type, Plan Status
// ---------------------------------------------------------------------------
const PLAN _TYPE _DEFS = [
{ key : 'decommission' , label : 'Decommission' , color : '#EF4444' } ,
{ key : 'remediation' , label : 'Remediation' , color : '#0EA5E9' } ,
{ key : 'false_positive' , label : 'False Positive' , color : '#A855F7' } ,
{ key : 'risk_acceptance' , label : 'Risk Acceptance' , color : '#F59E0B' } ,
{ key : 'scan_exclusion' , label : 'Scan Exclusion' , color : '#64748B' } ,
] ;
function getStatusColor ( status ) {
if ( status === 'active' ) return '#10B981' ;
if ( status === 'expired' ) return '#EF4444' ;
if ( status === 'completed' ) return '#0EA5E9' ;
return '#64748B' ;
}
function AtlasCoverageDonut ( { hostsWithPlans , hostsWithoutPlans , totalHosts } ) {
const SIZE = 180 ;
const CX = SIZE / 2 ;
const CY = SIZE / 2 ;
const OUTER = 72 ;
const INNER = 48 ;
if ( totalHosts === 0 ) {
return (
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'center' , height : ` ${ SIZE } px ` } } >
< p style = { { fontFamily : 'monospace' , fontSize : '0.75rem' , color : '#475569' } } > No data — run Atlas Sync < / p >
< / d i v >
) ;
}
const segments = [
{ label : 'With Plans' , count : hostsWithPlans , color : '#10B981' , start : 0 , end : ( hostsWithPlans / totalHosts ) * 360 } ,
{ label : 'Without Plans' , count : hostsWithoutPlans , color : '#F59E0B' , start : ( hostsWithPlans / totalHosts ) * 360 , end : 360 } ,
] . 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 . label }
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' } } >
{ totalHosts . 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' } } >
HOSTS
< / 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 / totalHosts ) * 100 ) . toFixed ( 1 ) } % )
< / s p a n >
< / d i v >
< / d i v >
< / d i v >
) ) }
< / d i v >
< / d i v >
) ;
}
function AtlasPlanTypeDonut ( { plansByType , totalPlans } ) {
const SIZE = 180 ;
const CX = SIZE / 2 ;
const CY = SIZE / 2 ;
const OUTER = 72 ;
const INNER = 48 ;
if ( totalPlans === 0 ) {
return (
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'center' , height : ` ${ SIZE } px ` } } >
< p style = { { fontFamily : 'monospace' , fontSize : '0.75rem' , color : '#475569' } } > No plans — run Atlas Sync < / p >
< / d i v >
) ;
}
let cursor = 0 ;
const segments = PLAN _TYPE _DEFS . map ( ( def ) => {
const count = plansByType [ def . key ] || 0 ;
const start = cursor ;
const end = count > 0 ? cursor + ( count / totalPlans ) * 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' } } >
{ totalPlans . 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' } } >
PLANS
< / 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 / totalPlans ) * 100 ) . toFixed ( 0 ) } % )
< / s p a n >
< / d i v >
< / d i v >
) ) }
< / d i v >
< / d i v >
) ;
}
function AtlasPlanStatusDonut ( { plansByStatus , totalPlans } ) {
const SIZE = 180 ;
const CX = SIZE / 2 ;
const CY = SIZE / 2 ;
const OUTER = 72 ;
const INNER = 48 ;
if ( totalPlans === 0 ) {
return (
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'center' , height : ` ${ SIZE } px ` } } >
< p style = { { fontFamily : 'monospace' , fontSize : '0.75rem' , color : '#475569' } } > No plans — run Atlas Sync < / p >
< / d i v >
) ;
}
const entries = Object . entries ( plansByStatus ) . filter ( ( [ , count ] ) => count > 0 ) ;
let cursor = 0 ;
const segments = entries . map ( ( [ status , count ] ) => {
const start = cursor ;
const end = cursor + ( count / totalPlans ) * 360 ;
cursor = end ;
return {
key : status ,
label : status . charAt ( 0 ) . toUpperCase ( ) + status . slice ( 1 ) ,
color : getStatusColor ( status ) ,
count ,
start ,
end ,
} ;
} ) ;
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' } } >
{ totalPlans . 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' } } >
STATUS
< / 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 / totalPlans ) * 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
// ---------------------------------------------------------------------------
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
function OverrideCell ( { findingId , field , originalValue , initialOverride , canWrite , suffix } ) {
2026-03-13 15:39:37 -06:00
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 >
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
{ suffix }
2026-03-13 15:39:37 -06:00
< / 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
// ---------------------------------------------------------------------------
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
function TableCell ( { colKey , finding , canWrite , onCveMouseEnter , onCveMouseLeave , fpSubmissions , onEditSubmission , atlasStatusMap , onAtlasBadgeClick } ) {
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 }
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
suffix = {
< AtlasBadge
hostId = { finding . hostId }
atlasStatus = { atlasStatusMap ? atlasStatusMap . get ( finding . hostId ) : undefined }
onClick = { onAtlasBadgeClick }
/ >
}
2026-03-13 15:39:37 -06:00
/ >
) ;
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
// ---------------------------------------------------------------------------
2026-04-24 21:49:04 +00:00
function BulkHideToolbar ( { count , onHide , onClear , onAtlasBulk , canWrite } ) {
2026-04-15 13:15:01 -06:00
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 >
2026-04-24 21:49:04 +00:00
{ /* Bulk Atlas Action Plan button */ }
{ canWrite && onAtlasBulk && (
< button
onClick = { onAtlasBulk }
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' ,
} }
>
< Database style = { { width : '12px' , height : '12px' } } / >
Atlas Action Plan
< / b u t t o n >
) }
2026-04-15 13:15:01 -06:00
{ /* 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-04-24 21:49:04 +00:00
// ---------------------------------------------------------------------------
// BulkAtlasModal — modal for creating action plans on multiple hosts at once
// ---------------------------------------------------------------------------
const BULK _PLAN _TYPES = [ 'decommission' , 'remediation' , 'false_positive' , 'risk_acceptance' , 'scan_exclusion' ] ;
const BULK _PLAN _TYPE _COLORS = {
remediation : '#0EA5E9' , decommission : '#EF4444' , false _positive : '#F59E0B' ,
risk _acceptance : '#A855F7' , scan _exclusion : '#64748B' ,
} ;
function BulkAtlasModal ( { selectedFindings , onClose , onSuccess } ) {
const [ planType , setPlanType ] = useState ( 'risk_acceptance' ) ;
const [ commitDate , setCommitDate ] = useState ( '' ) ;
const [ qualysId , setQualysId ] = useState ( '' ) ;
const [ jiraVnr , setJiraVnr ] = useState ( '' ) ;
const [ archerExc , setArcherExc ] = useState ( '' ) ;
const [ submitting , setSubmitting ] = useState ( false ) ;
const [ error , setError ] = useState ( null ) ;
const [ result , setResult ] = useState ( null ) ;
const [ typeOpen , setTypeOpen ] = useState ( false ) ;
const typeRef = useRef ( null ) ;
// Close type dropdown on outside click
useEffect ( ( ) => {
if ( ! typeOpen ) return ;
const handler = ( e ) => { if ( typeRef . current && ! typeRef . current . contains ( e . target ) ) setTypeOpen ( false ) ; } ;
document . addEventListener ( 'mousedown' , handler ) ;
return ( ) => document . removeEventListener ( 'mousedown' , handler ) ;
} , [ typeOpen ] ) ;
// Deduplicate host IDs from selected findings
const hostEntries = useMemo ( ( ) => {
const seen = new Map ( ) ;
for ( const f of selectedFindings ) {
if ( f . hostId && ! seen . has ( f . hostId ) ) {
seen . set ( f . hostId , { hostId : f . hostId , hostName : f . overrides ? . hostName || f . hostName || f . ipAddress || String ( f . hostId ) } ) ;
}
}
return [ ... seen . values ( ) ] ;
} , [ selectedFindings ] ) ;
const hostIds = useMemo ( ( ) => hostEntries . map ( h => h . hostId ) , [ hostEntries ] ) ;
const handleSubmit = async ( ) => {
if ( ! commitDate ) { setError ( 'Commit date is required' ) ; return ; }
if ( hostIds . length === 0 ) { setError ( 'No valid host IDs in selection' ) ; return ; }
setSubmitting ( true ) ;
setError ( null ) ;
try {
const body = { host _ids : hostIds , plan _type : planType , commit _date : commitDate } ;
if ( qualysId . trim ( ) ) body . qualys _id = qualysId . trim ( ) ;
if ( jiraVnr . trim ( ) ) body . jira _vnr = jiraVnr . trim ( ) ;
if ( archerExc . trim ( ) ) body . archer _exc = archerExc . trim ( ) ;
const res = await fetch ( ` ${ API _BASE } /atlas/hosts/bulk-action-plans ` , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( body ) ,
} ) ;
const data = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! res . ok ) throw new Error ( data . error || data . detail || ` Failed ( ${ res . status } ) ` ) ;
setResult ( data ) ;
if ( onSuccess ) onSuccess ( ) ;
} catch ( err ) {
setError ( err . message ) ;
} finally {
setSubmitting ( false ) ;
}
} ;
const inputSt = {
width : '100%' , boxSizing : 'border-box' ,
background : 'rgba(14,165,233,0.06)' , border : '1px solid rgba(14,165,233,0.2)' ,
borderRadius : '0.375rem' , color : '#E2E8F0' , padding : '0.5rem 0.625rem' ,
fontSize : '0.78rem' , fontFamily : "'JetBrains Mono', monospace" , outline : 'none' ,
} ;
const labelSt = {
display : 'block' , fontSize : '0.68rem' , fontFamily : "'JetBrains Mono', monospace" ,
color : '#94A3B8' , marginBottom : '0.3rem' , textTransform : 'uppercase' , letterSpacing : '0.05em' ,
} ;
return ReactDOM . createPortal (
< >
{ /* Backdrop */ }
< div onClick = { onClose } style = { { position : 'fixed' , inset : 0 , background : 'rgba(0,0,0,0.5)' , zIndex : 60 } } / >
{ /* Modal */ }
< div style = { {
position : 'fixed' , top : '50%' , left : '50%' , transform : 'translate(-50%, -50%)' ,
width : '520px' , maxHeight : '80vh' , overflowY : 'auto' ,
background : '#0A1220' ,
border : '1px solid rgba(14,165,233,0.25)' ,
borderRadius : '0.5rem' ,
boxShadow : '0 16px 48px rgba(0,0,0,0.6)' ,
zIndex : 61 ,
fontFamily : "'JetBrains Mono', monospace" ,
} } >
{ /* Header */ }
< div style = { {
display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' ,
padding : '1rem 1.25rem' ,
borderBottom : '1px solid rgba(255,255,255,0.06)' ,
} } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' } } >
< Database style = { { width : 16 , height : 16 , color : '#0EA5E9' } } / >
< span style = { { fontSize : '0.85rem' , fontWeight : 700 , color : '#E2E8F0' } } >
Bulk Atlas Action Plan
< / s p a n >
< / d i v >
< button onClick = { onClose } style = { { background : 'none' , border : 'none' , cursor : 'pointer' , color : '#475569' , padding : '0.25rem' } } >
< X style = { { width : 18 , height : 18 } } / >
< / b u t t o n >
< / d i v >
{ /* Success state */ }
{ result ? (
< div style = { { padding : '1.5rem 1.25rem' , textAlign : 'center' } } >
< Check style = { { width : 32 , height : 32 , color : '#10B981' , margin : '0 auto 0.75rem' } } / >
< div style = { { fontSize : '0.85rem' , color : '#E2E8F0' , fontWeight : 600 , marginBottom : '0.5rem' } } >
Action plans created
< / d i v >
< div style = { { fontSize : '0.72rem' , color : '#94A3B8' , marginBottom : '1rem' } } >
{ hostIds . length } host { hostIds . length !== 1 ? 's' : '' } — { planType . replace ( /_/g , ' ' ) }
< / d i v >
< button onClick = { onClose } style = { {
padding : '0.5rem 1.25rem' ,
background : 'rgba(14,165,233,0.15)' , border : '1px solid #0EA5E9' ,
borderRadius : '0.375rem' , color : '#38BDF8' ,
fontSize : '0.75rem' , fontWeight : 600 , cursor : 'pointer' ,
} } >
Close
< / b u t t o n >
< / d i v >
) : (
< div style = { { padding : '1rem 1.25rem' } } >
{ /* Host summary */ }
< div style = { {
marginBottom : '1rem' , padding : '0.625rem 0.75rem' ,
background : 'rgba(14,165,233,0.06)' ,
border : '1px solid rgba(14,165,233,0.15)' ,
borderRadius : '0.375rem' ,
} } >
< div style = { { fontSize : '0.68rem' , color : '#94A3B8' , textTransform : 'uppercase' , letterSpacing : '0.05em' , marginBottom : '0.4rem' } } >
{ hostEntries . length } unique host { hostEntries . length !== 1 ? 's' : '' } from { selectedFindings . length } selected finding { selectedFindings . length !== 1 ? 's' : '' }
< / d i v >
< div style = { { maxHeight : '100px' , overflowY : 'auto' , fontSize : '0.72rem' , color : '#CBD5E1' , lineHeight : 1.6 } } >
{ hostEntries . map ( h => (
< div key = { h . hostId } style = { { display : 'flex' , justifyContent : 'space-between' , gap : '0.5rem' } } >
< span style = { { overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' } } > { h . hostName } < / s p a n >
< span style = { { color : '#475569' , flexShrink : 0 } } > { h . hostId } < / s p a n >
< / d i v >
) ) }
< / d i v >
< / d i v >
{ /* Plan type dropdown */ }
< div style = { { marginBottom : '0.75rem' } } >
< label style = { labelSt } > Plan Type < / l a b e l >
< div ref = { typeRef } style = { { position : 'relative' } } >
< button type = "button" onClick = { ( ) => setTypeOpen ( ! typeOpen ) } style = { {
... inputSt , display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' ,
cursor : 'pointer' , textAlign : 'left' ,
borderColor : typeOpen ? 'rgba(14,165,233,0.5)' : 'rgba(14,165,233,0.2)' ,
} } >
< span style = { { color : BULK _PLAN _TYPE _COLORS [ planType ] , fontWeight : 600 , textTransform : 'uppercase' , letterSpacing : '0.03em' } } >
{ planType . replace ( /_/g , ' ' ) }
< / s p a n >
< ChevronDown style = { { width : 14 , height : 14 , color : '#475569' , transform : typeOpen ? 'rotate(180deg)' : 'none' , transition : 'transform 0.15s' } } / >
< / b u t t o n >
{ typeOpen && (
< div style = { {
position : 'absolute' , top : '100%' , left : 0 , right : 0 , marginTop : '4px' ,
background : '#0F1A2E' , border : '1px solid rgba(14,165,233,0.25)' ,
borderRadius : '0.375rem' , boxShadow : '0 8px 24px rgba(0,0,0,0.5)' ,
zIndex : 65 , overflow : 'hidden' ,
} } >
{ BULK _PLAN _TYPES . map ( t => (
< div key = { t } onClick = { ( ) => { setPlanType ( t ) ; setTypeOpen ( false ) ; } } style = { {
padding : '0.5rem 0.625rem' , cursor : 'pointer' ,
background : t === planType ? 'rgba(14,165,233,0.12)' : 'transparent' ,
color : BULK _PLAN _TYPE _COLORS [ t ] , fontSize : '0.78rem' ,
fontWeight : 600 , textTransform : 'uppercase' , letterSpacing : '0.03em' ,
} }
onMouseEnter = { e => { if ( t !== planType ) e . currentTarget . style . background = 'rgba(14,165,233,0.06)' ; } }
onMouseLeave = { e => { if ( t !== planType ) e . currentTarget . style . background = 'transparent' ; } }
>
{ t . replace ( /_/g , ' ' ) }
< / d i v >
) ) }
< / d i v >
) }
< / d i v >
< / d i v >
{ /* Commit date */ }
< div style = { { marginBottom : '0.75rem' } } >
< label style = { labelSt } > Commit Date < / l a b e l >
< input type = "date" value = { commitDate } onChange = { e => setCommitDate ( e . target . value ) }
style = { { ... inputSt , colorScheme : 'dark' } } / >
< / d i v >
{ /* Optional fields — shown based on plan type */ }
{ ( planType === 'remediation' || planType === 'false_positive' ) && (
< div style = { { marginBottom : '0.75rem' } } >
< label style = { labelSt } > Qualys ID < span style = { { color : '#475569' , textTransform : 'none' } } > ( optional ) < / s p a n > < / l a b e l >
< input value = { qualysId } onChange = { e => setQualysId ( e . target . value ) }
placeholder = "QID-12345" style = { inputSt } / >
< / d i v >
) }
{ planType === 'false_positive' && (
< div style = { { marginBottom : '0.75rem' } } >
< label style = { labelSt } > Jira VNR < span style = { { color : '#475569' , textTransform : 'none' } } > ( optional ) < / s p a n > < / l a b e l >
< input value = { jiraVnr } onChange = { e => setJiraVnr ( e . target . value ) }
placeholder = "VNR-67890" style = { inputSt } / >
< / d i v >
) }
{ ( planType === 'risk_acceptance' || planType === 'scan_exclusion' ) && (
< div style = { { marginBottom : '0.75rem' } } >
< label style = { labelSt } > Archer EXC < span style = { { color : '#475569' , textTransform : 'none' } } > ( optional ) < / s p a n > < / l a b e l >
< input value = { archerExc } onChange = { e => setArcherExc ( e . target . value ) }
placeholder = "EXC-54321" style = { inputSt } / >
< / d i v >
) }
{ /* Error */ }
{ error && (
< div style = { {
marginBottom : '0.75rem' , padding : '0.5rem 0.75rem' ,
background : 'rgba(239,68,68,0.1)' , border : '1px solid rgba(239,68,68,0.3)' ,
borderRadius : '0.375rem' , color : '#F87171' , fontSize : '0.75rem' ,
display : 'flex' , alignItems : 'center' , gap : '0.4rem' ,
} } >
< AlertCircle style = { { width : 14 , height : 14 , flexShrink : 0 } } / > { error }
< / d i v >
) }
{ /* Submit */ }
< button onClick = { handleSubmit } disabled = { submitting } style = { {
width : '100%' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' , gap : '0.4rem' ,
padding : '0.6rem 1rem' ,
background : submitting ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.15)' ,
border : '1px solid #0EA5E9' , borderRadius : '0.375rem' ,
color : submitting ? '#475569' : '#38BDF8' ,
fontSize : '0.78rem' , fontWeight : 600 , cursor : submitting ? 'not-allowed' : 'pointer' ,
textTransform : 'uppercase' , letterSpacing : '0.05em' ,
} } >
{ submitting ? < Loader style = { { width : 14 , height : 14 , animation : 'spin 1s linear infinite' } } / > : < Database style = { { width : 14 , height : 14 } } / > }
{ submitting ? 'Creating...' : ` Create ${ hostEntries . length } Action Plan ${ hostEntries . length !== 1 ? 's' : '' } ` }
< / b u t t o n >
< / d i v >
) }
< / d i v >
< / > ,
document . body
) ;
}
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 ) ;
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
// Atlas action plan state
2026-04-24 17:30:06 +00:00
const [ metricsTab , setMetricsTab ] = useState ( 'ivanti' ) ;
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
const [ atlasStatusMap , setAtlasStatusMap ] = useState ( new Map ( ) ) ;
const [ atlasSyncing , setAtlasSyncing ] = useState ( false ) ;
const [ atlasError , setAtlasError ] = useState ( null ) ;
const [ atlasPanelOpen , setAtlasPanelOpen ] = useState ( false ) ;
const [ atlasSelectedHostId , setAtlasSelectedHostId ] = useState ( null ) ;
const [ atlasSelectedHostName , setAtlasSelectedHostName ] = useState ( null ) ;
const [ atlasSelectedFindingId , setAtlasSelectedFindingId ] = useState ( null ) ;
2026-04-24 21:49:04 +00:00
const [ bulkAtlasOpen , setBulkAtlasOpen ] = useState ( false ) ;
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
2026-04-24 17:30:06 +00:00
// Atlas metrics state (for Atlas Coverage tab donut charts)
const [ atlasMetrics , setAtlasMetrics ] = useState ( null ) ;
const [ atlasMetricsLoading , setAtlasMetricsLoading ] = useState ( false ) ;
const [ atlasMetricsError , setAtlasMetricsError ] = useState ( 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 Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
const fetchAtlasStatus = useCallback ( async ( ) => {
try {
const res = await fetch ( ` ${ API _BASE } /atlas/status ` , { credentials : 'include' } ) ;
if ( res . ok ) {
const data = await res . json ( ) ;
const map = new Map ( ) ;
data . forEach ( row => map . set ( row . host _id , row ) ) ;
setAtlasStatusMap ( map ) ;
}
} catch ( err ) {
console . error ( '[Atlas] Failed to fetch status:' , err . message ) ;
}
} , [ ] ) ;
2026-04-24 17:30:06 +00:00
const fetchAtlasMetrics = useCallback ( async ( ) => {
setAtlasMetricsLoading ( true ) ;
setAtlasMetricsError ( null ) ;
try {
const res = await fetch ( ` ${ API _BASE } /atlas/metrics ` , { credentials : 'include' } ) ;
if ( res . ok ) {
const data = await res . json ( ) ;
setAtlasMetrics ( data ) ;
} else {
const err = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
setAtlasMetricsError ( err . error || 'Failed to fetch Atlas metrics' ) ;
}
} catch ( err ) {
setAtlasMetricsError ( err . message ) ;
} finally {
setAtlasMetricsLoading ( false ) ;
}
} , [ ] ) ;
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 ( ) ;
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
fetchAtlasStatus ( ) ;
2026-04-24 17:30:06 +00:00
fetchAtlasMetrics ( ) ;
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 >
2026-04-24 17:30:06 +00:00
< div style = { { marginLeft : 'auto' , display : 'flex' , gap : '0.25rem' } } role = "tablist" >
{ [ { key : 'ivanti' , label : 'Ivanti Findings' } , { key : 'atlas' , label : 'Atlas Coverage' } ] . map ( tab => {
const isActive = metricsTab === tab . key ;
return (
< button
key = { tab . key }
role = "tab"
aria - selected = { isActive }
tabIndex = { 0 }
onClick = { ( ) => setMetricsTab ( tab . key ) }
onKeyDown = { ( e ) => { if ( e . key === 'Enter' ) setMetricsTab ( tab . key ) ; } }
style = { {
background : 'transparent' ,
border : 'none' ,
borderBottom : isActive ? '2px solid #F59E0B' : '2px solid transparent' ,
color : isActive ? '#F59E0B' : '#64748B' ,
fontFamily : 'monospace' ,
fontSize : '0.7rem' ,
textTransform : 'uppercase' ,
letterSpacing : '0.08em' ,
padding : '0.375rem 0.75rem' ,
cursor : 'pointer' ,
transition : 'background 0.15s, color 0.15s'
} }
onMouseEnter = { ( e ) => { if ( ! isActive ) e . currentTarget . style . background = 'rgba(245, 158, 11, 0.06)' ; } }
onMouseLeave = { ( e ) => { e . currentTarget . style . background = 'transparent' ; } }
>
{ tab . label }
< / 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 >
2026-04-24 17:30:06 +00:00
< div role = "tabpanel" >
{ metricsTab === 'ivanti' && (
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 >
2026-04-24 17:30:06 +00:00
) }
{ metricsTab === 'atlas' && (
( atlasMetricsLoading || ( ! atlasMetrics && ! atlasMetricsError ) ) ? (
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'center' , padding : '3rem 0' , gap : '0.5rem' } } >
< Loader style = { { width : '24px' , height : '24px' , color : '#0EA5E9' , animation : 'spin 1s linear infinite' } } / >
< / d i v >
) : atlasMetricsError ? (
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'center' , padding : '2rem' , gap : '0.375rem' } } >
< AlertCircle style = { { width : '14px' , height : '14px' , color : '#EF4444' , flexShrink : 0 } } / >
< span style = { { fontFamily : 'monospace' , fontSize : '0.75rem' , color : '#FCA5A5' } } > { atlasMetricsError } < / s p a n >
< / d i v >
) : (
< div style = { { display : 'flex' , gap : '3rem' , flexWrap : 'wrap' , alignItems : 'flex-start' } } >
{ /* Host Coverage 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' } } >
Host Coverage
< / d i v >
< AtlasCoverageDonut
hostsWithPlans = { atlasMetrics . hostsWithPlans }
hostsWithoutPlans = { atlasMetrics . hostsWithoutPlans }
totalHosts = { atlasMetrics . totalHosts }
/ >
< / d i v >
{ /* Divider */ }
< div style = { { width : '1px' , background : 'rgba(255,255,255,0.06)' , alignSelf : 'stretch' , flexShrink : 0 } } / >
{ /* Plan Types 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' } } >
Plan Types
< / d i v >
< AtlasPlanTypeDonut
plansByType = { atlasMetrics . plansByType }
totalPlans = { atlasMetrics . totalPlans }
/ >
< / d i v >
{ /* Divider */ }
< div style = { { width : '1px' , background : 'rgba(255,255,255,0.06)' , alignSelf : 'stretch' , flexShrink : 0 } } / >
{ /* Plan Status 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' } } >
Plan Status
< / d i v >
< AtlasPlanStatusDonut
plansByStatus = { atlasMetrics . plansByStatus }
totalPlans = { atlasMetrics . totalPlans }
/ >
< / 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
< / d i v >
2026-04-02 10:12:04 -06:00
{ / * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Panel 1.5 — Open vs Closed trend over time
-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- * / }
2026-04-24 20:34:34 +00:00
{ metricsTab === 'ivanti' && < AnomalyBanner / > }
2026-04-24 17:30:06 +00:00
{ metricsTab === 'ivanti' && < IvantiCountsChart / > }
2026-04-02 10:12:04 -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
{ / * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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 } / >
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
< button
onClick = { async ( ) => {
setAtlasSyncing ( true ) ;
setAtlasError ( null ) ;
try {
const res = await fetch ( ` ${ API _BASE } /atlas/sync ` , { method : 'POST' , credentials : 'include' } ) ;
if ( ! res . ok ) {
const data = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
throw new Error ( data . error || 'Atlas sync failed' ) ;
}
await fetchAtlasStatus ( ) ;
2026-04-24 17:30:06 +00:00
await fetchAtlasMetrics ( ) ;
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
} catch ( err ) {
setAtlasError ( err . message ) ;
} finally {
setAtlasSyncing ( false ) ;
}
} }
disabled = { atlasSyncing || ! canWrite ( ) }
title = { ! canWrite ( ) ? 'Insufficient permissions' : 'Sync Atlas action plan status' }
style = { {
display : 'inline-flex' , alignItems : 'center' , gap : '0.4rem' ,
padding : '0.4rem 0.75rem' ,
background : atlasSyncing ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.06)' ,
border : '1px solid rgba(14,165,233,0.2)' ,
borderRadius : '0.375rem' ,
color : atlasSyncing ? '#475569' : '#0EA5E9' ,
fontSize : '0.72rem' ,
fontFamily : "'JetBrains Mono', monospace" ,
fontWeight : 600 ,
cursor : atlasSyncing || ! canWrite ( ) ? 'not-allowed' : 'pointer' ,
opacity : ! canWrite ( ) ? 0.5 : 1 ,
textTransform : 'uppercase' ,
letterSpacing : '0.05em' ,
} }
>
{ atlasSyncing
? < Loader style = { { width : 13 , height : 13 , animation : 'spin 1s linear infinite' } } / >
2026-04-23 22:18:23 +00:00
: < AtlasIcon style = { { width : 13 , height : 13 } } / > }
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
Atlas
< / b u t t o n >
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 >
) }
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
{ atlasError && (
< 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' } } > Atlas : { atlasError } < / s p a 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
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 ( ) ) }
2026-04-24 21:49:04 +00:00
onAtlasBulk = { ( ) => setBulkAtlasOpen ( true ) }
canWrite = { canWrite ( ) }
2026-04-15 13:15:01 -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
< 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 ) => (
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
< TableCell key = { col . key } colKey = { col . key } finding = { finding } canWrite = { canWrite ( ) } onCveMouseEnter = { handleCveMouseEnter } onCveMouseLeave = { handleCveMouseLeave } fpSubmissions = { fpSubmissions } onEditSubmission = { handleEditSubmission } atlasStatusMap = { atlasStatusMap } onAtlasBadgeClick = { ( hostId ) => { setAtlasSelectedHostId ( hostId ) ; setAtlasSelectedHostName ( finding . hostName || finding . ipAddress || '' ) ; setAtlasSelectedFindingId ( finding . id || null ) ; setAtlasPanelOpen ( true ) ; } } / >
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 }
/ >
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
{ atlasPanelOpen && atlasSelectedHostId && (
< AtlasSlideOutPanel
hostId = { atlasSelectedHostId }
hostName = { atlasSelectedHostName }
findingId = { atlasSelectedFindingId }
onClose = { ( ) => {
setAtlasPanelOpen ( false ) ;
setAtlasSelectedHostId ( null ) ;
setAtlasSelectedHostName ( null ) ;
setAtlasSelectedFindingId ( null ) ;
} }
canWrite = { canWrite ( ) }
onPlanChange = { fetchAtlasStatus }
/ >
) }
2026-04-24 21:49:04 +00:00
{ bulkAtlasOpen && (
< BulkAtlasModal
selectedFindings = { sorted . filter ( f => selectedRowIds . has ( String ( f . id ) ) ) }
onClose = { ( ) => setBulkAtlasOpen ( false ) }
onSuccess = { ( ) => { fetchAtlasStatus ( ) ; } }
/ >
) }
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
}