2026-03-11 13:03:17 -06:00
|
|
|
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
|
|
|
|
import ReactDOM from 'react-dom';
|
2026-04-07 16:20:24 -06:00
|
|
|
|
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle } 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-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 */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Sort accessor by column key
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
function getVal(finding, key) {
|
|
|
|
|
|
switch (key) {
|
2026-03-11 14:23:50 -06:00
|
|
|
|
case 'findingId': return finding.id ?? '';
|
2026-03-11 12:47:11 -06:00
|
|
|
|
case 'severity': return finding.severity ?? 0;
|
|
|
|
|
|
case 'title': return finding.title ?? '';
|
|
|
|
|
|
case 'hostName': return finding.hostName ?? '';
|
|
|
|
|
|
case 'ipAddress': return finding.ipAddress ?? '';
|
|
|
|
|
|
case 'dns': return finding.dns ?? '';
|
|
|
|
|
|
case 'dueDate': return finding.dueDate ?? '';
|
|
|
|
|
|
case 'slaStatus': return finding.slaStatus ?? '';
|
2026-03-11 13:17:01 -06:00
|
|
|
|
case 'cves': return (finding.cves || []).length; // sort by CVE count
|
2026-03-11 13:03:17 -06:00
|
|
|
|
case 'buOwnership': return finding.buOwnership ?? '';
|
2026-03-11 14:44:53 -06:00
|
|
|
|
case 'workflow': return finding.workflow?.id ?? '';
|
2026-03-11 12:47:11 -06:00
|
|
|
|
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
|
|
|
|
|
case 'note': return finding.note ?? '';
|
|
|
|
|
|
default: return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 13:03:17 -06:00
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-11 13:17:01 -06:00
|
|
|
|
// Filter accessor — severity → vrrGroup label; cves handled as multi-value
|
2026-03-11 13:03:17 -06:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
function getFilterVal(finding, key) {
|
|
|
|
|
|
if (key === 'severity') return finding.vrrGroup || '';
|
2026-03-11 13:17:01 -06:00
|
|
|
|
if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic
|
2026-03-11 14:44:53 -06:00
|
|
|
|
if (key === 'workflow') return finding.workflow?.id || '';
|
2026-03-11 13:03:17 -06:00
|
|
|
|
return String(getVal(finding, key) ?? '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 12:08:20 -06:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Export value accessor — plain text representation for CSV/XLSX
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
function getExportVal(finding, key) {
|
|
|
|
|
|
switch (key) {
|
|
|
|
|
|
case 'findingId': return finding.id ?? '';
|
|
|
|
|
|
case 'severity': return finding.vrrGroup ? `${finding.severity?.toFixed(2)} ${finding.vrrGroup}` : String(finding.severity ?? '');
|
|
|
|
|
|
case 'title': return finding.title ?? '';
|
|
|
|
|
|
case 'cves': return (finding.cves || []).join(', ');
|
|
|
|
|
|
case 'hostName': return finding.hostName ?? '';
|
|
|
|
|
|
case 'ipAddress': return finding.ipAddress ?? '';
|
|
|
|
|
|
case 'dns': return finding.dns ?? '';
|
|
|
|
|
|
case 'dueDate': return finding.dueDate ?? '';
|
|
|
|
|
|
case 'slaStatus': return finding.slaStatus ?? '';
|
|
|
|
|
|
case 'buOwnership': return finding.buOwnership ?? '';
|
|
|
|
|
|
case 'workflow': return finding.workflow ? `${finding.workflow.id} (${finding.workflow.state})` : '';
|
|
|
|
|
|
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
|
|
|
|
|
case 'note': return finding.note ?? '';
|
|
|
|
|
|
default: return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 13:06:54 -06:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Action coverage classification — used by chart and filter
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
const EXC_PATTERN = /EXC-\d+/i;
|
|
|
|
|
|
|
|
|
|
|
|
function classifyFinding(finding) {
|
|
|
|
|
|
if (finding.workflow != null) return 'fp';
|
|
|
|
|
|
if (EXC_PATTERN.test(finding.note || '')) return 'archer';
|
|
|
|
|
|
return 'pending';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 12:47:11 -06:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Style helpers
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
function severityColor(vrrGroup) {
|
|
|
|
|
|
switch ((vrrGroup || '').toUpperCase()) {
|
2026-03-11 12:47:11 -06:00
|
|
|
|
case 'CRITICAL': return { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#EF4444' };
|
|
|
|
|
|
case 'HIGH': return { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#F59E0B' };
|
|
|
|
|
|
case 'MEDIUM': return { bg: 'rgba(234,179,8,0.15)', border: '#EAB308', text: '#EAB308' };
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
default: return { bg: 'rgba(100,116,139,0.15)', border: '#64748B', text: '#94A3B8' };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function slaColor(slaStatus) {
|
|
|
|
|
|
switch ((slaStatus || '').toUpperCase()) {
|
2026-03-11 12:47:11 -06:00
|
|
|
|
case 'OVERDUE': return '#EF4444';
|
|
|
|
|
|
case 'AT_RISK': return '#F59E0B';
|
|
|
|
|
|
case 'WITHIN_SLA': return '#10B981';
|
|
|
|
|
|
default: return '#64748B';
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 12:47:11 -06:00
|
|
|
|
function dueDateColor(dueDate) {
|
|
|
|
|
|
if (!dueDate) return '#64748B';
|
|
|
|
|
|
const today = new Date();
|
|
|
|
|
|
const due = new Date(dueDate);
|
|
|
|
|
|
const diffDays = Math.ceil((due - today) / (1000 * 60 * 60 * 24));
|
2026-03-11 13:03:17 -06:00
|
|
|
|
if (diffDays < 0) return '#EF4444';
|
|
|
|
|
|
if (diffDays <= 30) return '#F59E0B';
|
2026-03-11 12:47:11 -06:00
|
|
|
|
return '#94A3B8';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 14:44:53 -06:00
|
|
|
|
function workflowStyle(state) {
|
2026-03-11 15:36:02 -06:00
|
|
|
|
// Colors reflect action urgency — all findings here are Open, so Approved won't appear.
|
2026-03-11 14:44:53 -06:00
|
|
|
|
switch ((state || '').toLowerCase()) {
|
2026-03-11 15:36:02 -06:00
|
|
|
|
case 'expired': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; // overdue — renew FP
|
|
|
|
|
|
case 'rejected': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; // denied — must remediate
|
|
|
|
|
|
case 'reworked': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; // challenged — resubmit FP
|
|
|
|
|
|
case 'actionable': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; // needs action
|
|
|
|
|
|
case 'requested': return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.35)', text: '#0EA5E9' }; // in flight — awaiting approval
|
|
|
|
|
|
default: return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' }; // unknown state
|
2026-03-11 14:44:53 -06:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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' }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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()}
|
|
|
|
|
|
</text>
|
|
|
|
|
|
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
|
|
|
|
|
TOTAL
|
|
|
|
|
|
</text>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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)}%)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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()}
|
|
|
|
|
|
</text>
|
|
|
|
|
|
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
|
|
|
|
|
TOTAL
|
|
|
|
|
|
</text>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
|
|
|
|
|
{seg.count}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
|
|
|
|
|
({total > 0 ? ((seg.count / total) * 100).toFixed(0) : 0}%)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-03-13 12:50:15 -06:00
|
|
|
|
</div>
|
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
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-03-13 12:50:15 -06:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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()}
|
|
|
|
|
|
</text>
|
|
|
|
|
|
<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
|
|
|
|
</text>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
|
|
|
|
|
{seg.count}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
|
|
|
|
|
({((seg.count / total) * 100).toFixed(0)}%)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
function SortIcon({ colKey, sort }) {
|
2026-03-11 12:47:11 -06:00
|
|
|
|
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
return sort.dir === 'asc'
|
2026-03-11 12:47:11 -06:00
|
|
|
|
? <ChevronUp style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />
|
|
|
|
|
|
: <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 15:39:37 -06:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// OverrideCell — inline editable hostname/dns with amber dot when overridden
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite }) {
|
|
|
|
|
|
const effective = initialOverride ?? originalValue ?? '';
|
|
|
|
|
|
const [value, setValue] = useState(effective);
|
|
|
|
|
|
const [isOverridden, setOverridden] = useState(!!initialOverride);
|
|
|
|
|
|
const [editing, setEditing] = useState(false);
|
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
const lastSaved = useRef(effective);
|
|
|
|
|
|
const inputRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
|
|
// Sync when the finding updates (e.g. after a full sync)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const eff = initialOverride ?? originalValue ?? '';
|
|
|
|
|
|
setValue(eff);
|
|
|
|
|
|
setOverridden(!!initialOverride);
|
|
|
|
|
|
lastSaved.current = eff;
|
|
|
|
|
|
}, [initialOverride, originalValue]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (editing && inputRef.current) inputRef.current.focus();
|
|
|
|
|
|
}, [editing]);
|
|
|
|
|
|
|
|
|
|
|
|
const persist = useCallback(async (newVal) => {
|
|
|
|
|
|
const trimmed = newVal.trim();
|
|
|
|
|
|
if (trimmed === lastSaved.current) return;
|
|
|
|
|
|
setSaving(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${API_BASE}/ivanti/findings/${findingId}/override`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ field, value: trimmed }),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const cleared = data.value === null;
|
|
|
|
|
|
const displayed = cleared ? (originalValue ?? '') : trimmed;
|
|
|
|
|
|
setValue(displayed);
|
|
|
|
|
|
setOverridden(!cleared);
|
|
|
|
|
|
lastSaved.current = displayed;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setValue(lastSaved.current); // revert on error
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setValue(lastSaved.current);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [findingId, field, originalValue]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleBlur = () => { setEditing(false); persist(value); };
|
|
|
|
|
|
const handleKeyDown = (e) => {
|
|
|
|
|
|
if (e.key === 'Enter') { e.target.blur(); }
|
|
|
|
|
|
if (e.key === 'Escape') { setValue(lastSaved.current); setEditing(false); }
|
|
|
|
|
|
};
|
|
|
|
|
|
const handleRevert = (e) => { e.stopPropagation(); setValue(''); persist(''); };
|
|
|
|
|
|
|
|
|
|
|
|
if (editing) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<td style={{ padding: '0.3rem 0.5rem' }}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
|
value={value}
|
|
|
|
|
|
onChange={(e) => setValue(e.target.value)}
|
|
|
|
|
|
onBlur={handleBlur}
|
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: '100%', minWidth: '120px',
|
|
|
|
|
|
background: 'rgba(14,165,233,0.08)',
|
|
|
|
|
|
border: '1px solid rgba(14,165,233,0.4)',
|
|
|
|
|
|
borderRadius: '0.25rem',
|
|
|
|
|
|
padding: '0.2rem 0.4rem',
|
|
|
|
|
|
color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.72rem',
|
|
|
|
|
|
outline: 'none',
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
|
|
|
|
|
<span
|
|
|
|
|
|
onClick={canWrite ? () => setEditing(true) : undefined}
|
|
|
|
|
|
title={isOverridden ? `Ivanti value: ${originalValue || '—'}\nClick 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' }} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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' }} />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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})</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{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
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{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}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<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
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<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
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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</div>
|
|
|
|
|
|
) : 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 —</span>
|
|
|
|
|
|
: <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
|
|
|
|
|
|
}
|
2026-03-11 13:03:17 -06:00
|
|
|
|
</label>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>,
|
|
|
|
|
|
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
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-13 15:39:37 -06:00
|
|
|
|
function TableCell({ colKey, finding, canWrite }) {
|
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 || '—'}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
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}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
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}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
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' }}>—</td>;
|
|
|
|
|
|
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) => (
|
|
|
|
|
|
<span key={cve} 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' }}>
|
|
|
|
|
|
{cve}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{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
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-11 12:47:11 -06:00
|
|
|
|
case 'hostName':
|
2026-03-13 15:39:37 -06:00
|
|
|
|
return (
|
|
|
|
|
|
<OverrideCell
|
|
|
|
|
|
findingId={finding.id}
|
|
|
|
|
|
field="hostName"
|
|
|
|
|
|
originalValue={finding.hostName}
|
|
|
|
|
|
initialOverride={finding.overrides?.hostName ?? null}
|
|
|
|
|
|
canWrite={canWrite}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-03-11 12:47:11 -06:00
|
|
|
|
case 'ipAddress':
|
|
|
|
|
|
return (
|
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
2026-03-13 15:39:37 -06:00
|
|
|
|
{finding.ipAddress || '—'}
|
2026-03-11 12:47:11 -06:00
|
|
|
|
</td>
|
|
|
|
|
|
);
|
|
|
|
|
|
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 || '—'}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
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 || '—'}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
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-', '')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span style={{ color: '#475569' }}>—</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
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' }}>—</td>;
|
|
|
|
|
|
const ws = workflowStyle(wf.state);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
|
|
|
|
|
<span
|
|
|
|
|
|
title={`${wf.id} · ${wf.state || 'Unknown'} · ${wf.type || ''}`}
|
|
|
|
|
|
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',
|
|
|
|
|
|
color: ws.text, cursor: 'default',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{wf.id}
|
|
|
|
|
|
<span style={{ fontSize: '0.58rem', opacity: 0.8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
|
|
|
|
|
{wf.state}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
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 || '—'}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
|
|
|
|
|
case 'note':
|
|
|
|
|
|
return (
|
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem' }}>
|
|
|
|
|
|
<NoteCell findingId={finding.id} initialNote={finding.note} />
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
|
|
|
|
|
default:
|
|
|
|
|
|
return <td style={{ padding: '0.45rem 0.75rem', color: '#64748B' }}>—</td>;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-03-26 14:52:06 -06:00
|
|
|
|
const isCard = queueForm.workflowType === 'CARD';
|
|
|
|
|
|
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
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#94A3B8', marginBottom: '0.75rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.id}>
|
|
|
|
|
|
{finding.id}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
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
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<label style={{ display: 'block', marginBottom: '0.625rem' }}>
|
|
|
|
|
|
<span style={{ display: 'block', fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.3rem' }}>
|
|
|
|
|
|
Vendor / Platform
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={queueForm.vendor}
|
|
|
|
|
|
onChange={(e) => setQueueForm((f) => ({ ...f, vendor: e.target.value }))}
|
|
|
|
|
|
placeholder="Juniper, Cisco, ADTRAN…"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: '100%', boxSizing: 'border-box',
|
|
|
|
|
|
background: 'rgba(14,165,233,0.05)',
|
|
|
|
|
|
border: '1px solid rgba(14,165,233,0.2)',
|
|
|
|
|
|
borderRadius: '0.25rem', padding: '0.35rem 0.5rem',
|
|
|
|
|
|
color: '#CBD5E1', fontSize: '0.78rem',
|
|
|
|
|
|
fontFamily: 'monospace', outline: 'none',
|
|
|
|
|
|
}}
|
|
|
|
|
|
onKeyDown={(e) => { if (e.key === 'Enter' && canSubmit) onAdd(); }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
)}
|
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
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div style={{ display: 'flex', gap: '0.375rem' }}>
|
2026-03-26 14:46:59 -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' },
|
|
|
|
|
|
].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
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
|
|
|
|
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onAdd}
|
|
|
|
|
|
disabled={!canSubmit}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
flex: 1, padding: '0.4rem',
|
|
|
|
|
|
background: canSubmit ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.05)',
|
|
|
|
|
|
border: `1px solid ${canSubmit ? 'rgba(14,165,233,0.4)' : 'rgba(14,165,233,0.1)'}`,
|
|
|
|
|
|
borderRadius: '0.25rem',
|
|
|
|
|
|
color: canSubmit ? '#0EA5E9' : '#334155',
|
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
|
|
|
|
|
|
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
|
|
|
|
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Add to Queue
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onCancel}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: '0.4rem 0.625rem',
|
|
|
|
|
|
background: 'none', border: 'none',
|
|
|
|
|
|
color: '#475569', fontFamily: 'monospace', fontSize: '0.72rem',
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Cancel
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>,
|
|
|
|
|
|
document.body
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-04-07 16:20:24 -06:00
|
|
|
|
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, 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
|
|
|
|
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());
|
|
|
|
|
|
|
|
|
|
|
|
// 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-03-26 14:52:06 -06:00
|
|
|
|
// CARD items 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-03-26 14:52:06 -06:00
|
|
|
|
const cardItems = items.filter((i) => i.workflow_type === 'CARD');
|
|
|
|
|
|
const otherItems = items.filter((i) => i.workflow_type !== 'CARD');
|
|
|
|
|
|
|
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) => ({
|
|
|
|
|
|
key: vendor, label: vendor, items: map[vendor], isCard: false,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
return cardItems.length > 0
|
|
|
|
|
|
? [{ key: '__CARD__', label: 'CARD', items: cardItems, isCard: true }, ...vendorGroups]
|
|
|
|
|
|
: 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
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{pendingCount > 0 && (
|
|
|
|
|
|
<span style={{
|
|
|
|
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
|
|
|
|
minWidth: '20px', height: '20px', padding: '0 5px',
|
|
|
|
|
|
background: 'rgba(14,165,233,0.2)',
|
|
|
|
|
|
border: '1px solid rgba(14,165,233,0.4)',
|
|
|
|
|
|
borderRadius: '999px',
|
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700', color: '#0EA5E9',
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{pendingCount}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X style={{ width: '18px', height: '18px' }} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Body */}
|
|
|
|
|
|
<div style={{ flex: 1, overflowY: 'auto', padding: '0.75rem 1.25rem' }}>
|
|
|
|
|
|
{items.length === 0 ? (
|
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '3rem 0', fontFamily: 'monospace', fontSize: '0.75rem', color: '#334155' }}>
|
|
|
|
|
|
No items in queue.<br />
|
|
|
|
|
|
<span style={{ fontSize: '0.68rem', color: '#1E293B', marginTop: '0.5rem', display: 'block' }}>
|
|
|
|
|
|
Check a row in the findings table to add it.
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-03-26 14:52:06 -06:00
|
|
|
|
) : grouped.map(({ key, label, items: groupItems, isCard }) => (
|
|
|
|
|
|
<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-03-26 14:52:06 -06:00
|
|
|
|
borderBottom: `1px solid ${isCard ? '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-03-26 14:52:06 -06:00
|
|
|
|
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: isCard ? '#10B981' : '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
|
|
|
|
|
{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
|
|
|
|
</span>
|
|
|
|
|
|
<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
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Items */}
|
2026-03-26 14:52:06 -06:00
|
|
|
|
{groupItems.map((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 done = item.status === 'complete';
|
2026-03-26 14:46:59 -06:00
|
|
|
|
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
|
|
|
|
|
|
: item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' }
|
|
|
|
|
|
: { col: '#10B981', rgb: '16,185,129' };
|
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 cves = item.cves || [];
|
|
|
|
|
|
const cveDisplay = cves.length > 0
|
|
|
|
|
|
? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '')
|
|
|
|
|
|
: '—';
|
2026-03-26 15:01:32 -06:00
|
|
|
|
const isCardItem = item.workflow_type === 'CARD';
|
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 (
|
|
|
|
|
|
<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',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-03-26 15:43:43 -06:00
|
|
|
|
{/* 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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
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
|
|
|
|
{/* Complete checkbox */}
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={done}
|
|
|
|
|
|
onChange={() => onUpdate(item.id, { status: done ? 'pending' : 'complete' })}
|
|
|
|
|
|
style={{ accentColor: '#10B981', width: '14px', height: '14px', flexShrink: 0, marginTop: '2px', cursor: 'pointer' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Content */}
|
|
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
|
|
|
|
|
color: done ? '#475569' : '#CBD5E1',
|
|
|
|
|
|
textDecoration: done ? 'line-through' : 'none',
|
|
|
|
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
|
|
|
|
}} title={item.finding_id}>
|
|
|
|
|
|
{item.finding_id}
|
|
|
|
|
|
</div>
|
2026-03-26 15:01:32 -06:00
|
|
|
|
{isCardItem ? (
|
2026-04-09 11:56:56 -06:00
|
|
|
|
<>
|
|
|
|
|
|
{item.hostname && (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
|
|
|
|
|
color: done ? '#334155' : '#94A3B8',
|
|
|
|
|
|
textDecoration: done ? 'line-through' : 'none',
|
|
|
|
|
|
marginTop: '2px',
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{item.hostname}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{item.ip_address && (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
|
|
|
|
|
color: done ? '#334155' : '#10B981',
|
|
|
|
|
|
textDecoration: done ? 'line-through' : 'none',
|
|
|
|
|
|
marginTop: '2px',
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{item.ip_address}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-03-26 15:01:32 -06:00
|
|
|
|
) : (
|
2026-04-09 11:56:56 -06:00
|
|
|
|
<>
|
|
|
|
|
|
{cves.length > 0 && (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.62rem',
|
|
|
|
|
|
color: done ? '#334155' : '#64748B',
|
|
|
|
|
|
textDecoration: done ? 'line-through' : 'none',
|
|
|
|
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
|
|
|
|
marginTop: '1px',
|
|
|
|
|
|
}} title={cves.join(', ')}>
|
|
|
|
|
|
{cveDisplay}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{item.hostname && (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.62rem',
|
|
|
|
|
|
color: done ? '#334155' : '#94A3B8',
|
|
|
|
|
|
textDecoration: done ? 'line-through' : 'none',
|
|
|
|
|
|
marginTop: '1px',
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{item.hostname}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{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}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
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>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Workflow type badge */}
|
|
|
|
|
|
<span style={{
|
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
|
padding: '0.1rem 0.35rem',
|
|
|
|
|
|
borderRadius: '0.2rem',
|
2026-03-26 14:46:59 -06:00
|
|
|
|
background: `rgba(${wfColor.rgb},0.12)`,
|
|
|
|
|
|
border: `1px solid rgba(${wfColor.rgb},0.3)`,
|
|
|
|
|
|
color: wfColor.col,
|
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
|
|
|
|
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{item.workflow_type}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Delete button */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => onDelete(item.id)}
|
|
|
|
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
|
|
|
|
|
onMouseEnter={(e) => e.currentTarget.style.color = '#EF4444'}
|
|
|
|
|
|
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
|
|
|
|
|
|
title="Remove from queue"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 style={{ width: '13px', height: '13px' }} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Footer */}
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
padding: '0.75rem 1.25rem',
|
|
|
|
|
|
borderTop: '1px solid rgba(255,255,255,0.06)',
|
|
|
|
|
|
flexShrink: 0,
|
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
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
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})
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
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})` : ''}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
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([]);
|
|
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
|
const [progress, setProgress] = useState({ step: '', current: 0, total: 0 });
|
|
|
|
|
|
const [errors, setErrors] = useState({});
|
|
|
|
|
|
const [result, setResult] = useState(null);
|
|
|
|
|
|
const fileInputRef = useRef(null);
|
|
|
|
|
|
const dropRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
|
|
// Reset form when modal opens
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
setName('');
|
|
|
|
|
|
setReason('');
|
|
|
|
|
|
setDescription('');
|
|
|
|
|
|
setExpirationDate('');
|
|
|
|
|
|
setScopeOverride('Authorized');
|
|
|
|
|
|
setFiles([]);
|
|
|
|
|
|
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 isAllowedExtension = (filename) => {
|
|
|
|
|
|
const ext = '.' + filename.split('.').pop().toLowerCase();
|
|
|
|
|
|
return ALLOWED_EXTENSIONS.includes(ext);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addFiles = (newFiles) => {
|
|
|
|
|
|
const fileErrors = [];
|
|
|
|
|
|
const valid = [];
|
|
|
|
|
|
Array.from(newFiles).forEach(f => {
|
|
|
|
|
|
if (!isAllowedExtension(f.name)) {
|
|
|
|
|
|
fileErrors.push(`"${f.name}" — file type not allowed. Accepted: ${ALLOWED_EXTENSIONS.join(', ')}`);
|
|
|
|
|
|
} else if (f.size > MAX_FILE_SIZE) {
|
|
|
|
|
|
fileErrors.push(`"${f.name}" — exceeds 10 MB limit`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
valid.push(f);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
if (fileErrors.length) {
|
|
|
|
|
|
setErrors(prev => ({ ...prev, files: fileErrors.join('; ') }));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
|
|
|
|
|
|
}
|
|
|
|
|
|
if (valid.length) setFiles(prev => [...prev, ...valid]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const removeFile = (idx) => {
|
|
|
|
|
|
setFiles(prev => prev.filter((_, i) => i !== idx));
|
|
|
|
|
|
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDrop = (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); };
|
|
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
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));
|
|
|
|
|
|
|
|
|
|
|
|
if (files.length > 0) {
|
|
|
|
|
|
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: files.length });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
|
|
const formatSize = (bytes) => {
|
|
|
|
|
|
if (bytes < 1024) return bytes + ' B';
|
|
|
|
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
|
|
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 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'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
|
|
|
|
|
<X size={16} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ padding: '1.5rem 1.25rem', textAlign: 'center' }}>
|
|
|
|
|
|
{result.success ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
|
|
|
|
<Check size={36} style={{ color: '#10B981' }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: '1.1rem', fontWeight: '700', color: '#F59E0B', marginBottom: '0.5rem' }}>
|
|
|
|
|
|
{result.generatedId || `Batch #${result.workflowBatchId}`}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: '0.78rem', color: '#94A3B8', marginBottom: '1rem' }}>
|
|
|
|
|
|
FP workflow created successfully with {selectedItems.length} finding{selectedItems.length !== 1 ? 's' : ''}.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{result.attachmentResults.length > 0 && (
|
|
|
|
|
|
<div style={{ textAlign: 'left', marginBottom: '1rem' }}>
|
|
|
|
|
|
<div style={labelStyle}>Attachments</div>
|
|
|
|
|
|
{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} />}
|
|
|
|
|
|
<span>{a.filename}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
|
|
|
|
<AlertTriangle size={36} style={{ color: '#EF4444' }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: '0.88rem', fontWeight: '600', color: '#E2E8F0', marginBottom: '0.5rem' }}>
|
|
|
|
|
|
{result.error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{result.generatedId && (
|
|
|
|
|
|
<div style={{ fontSize: '0.78rem', color: '#F59E0B', marginBottom: '0.5rem' }}>
|
|
|
|
|
|
Workflow was created: {result.generatedId}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{result.attachmentResults?.length > 0 && (
|
|
|
|
|
|
<div style={{ textAlign: 'left', marginBottom: '1rem' }}>
|
|
|
|
|
|
<div style={labelStyle}>Attachment Results</div>
|
|
|
|
|
|
{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} />}
|
|
|
|
|
|
<span>{a.filename}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<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
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>,
|
|
|
|
|
|
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
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button onClick={() => { if (!submitting) onClose(); }} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
|
|
|
|
|
<X size={16} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Selected findings summary */}
|
|
|
|
|
|
<div style={sectionStyle}>
|
|
|
|
|
|
<div style={labelStyle}>Selected Findings ({selectedItems.length})</div>
|
|
|
|
|
|
<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}</span>
|
|
|
|
|
|
<span style={{ color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{item.finding_title || '—'}</span>
|
|
|
|
|
|
{item.cves_json && (() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cves = JSON.parse(item.cves_json);
|
|
|
|
|
|
return cves.length > 0 ? <span style={{ color: '#64748B', flexShrink: 0 }}>{cves.join(', ')}</span> : null;
|
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
|
})()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Form fields */}
|
|
|
|
|
|
<div style={sectionStyle}>
|
|
|
|
|
|
{/* Name */}
|
|
|
|
|
|
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
|
|
|
|
|
<span style={labelStyle}>Workflow Name <span style={{ color: '#EF4444' }}>*</span></span>
|
|
|
|
|
|
<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}</div>}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Reason */}
|
|
|
|
|
|
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
|
|
|
|
|
<span style={labelStyle}>Reason / Justification <span style={{ color: '#EF4444' }}>*</span></span>
|
|
|
|
|
|
<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}</div>}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Description */}
|
|
|
|
|
|
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
|
|
|
|
|
<span style={labelStyle}>Description (optional)</span>
|
|
|
|
|
|
<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}</div>}
|
|
|
|
|
|
<div style={{ fontSize: '0.62rem', color: '#475569', textAlign: 'right', marginTop: '0.15rem' }}>{description.length}/2000</div>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Expiration date */}
|
|
|
|
|
|
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
|
|
|
|
|
<span style={labelStyle}>Expiration Date <span style={{ color: '#EF4444' }}>*</span></span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
value={expirationDate}
|
|
|
|
|
|
onChange={e => setExpirationDate(e.target.value)}
|
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
|
style={errors.expirationDate ? inputErrorStyle : inputStyle}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{errors.expirationDate && <div style={errorTextStyle}>{errors.expirationDate}</div>}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Scope override toggle */}
|
|
|
|
|
|
<div style={{ marginBottom: '0.25rem' }}>
|
|
|
|
|
|
<span style={labelStyle}>Scope Override Authorization</span>
|
|
|
|
|
|
<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}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* File upload */}
|
|
|
|
|
|
<div style={sectionStyle}>
|
|
|
|
|
|
<div style={labelStyle}>Attachments</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={dropRef}
|
|
|
|
|
|
onDrop={handleDrop}
|
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
border: '1px dashed rgba(14,165,233,0.25)',
|
|
|
|
|
|
borderRadius: '0.375rem',
|
|
|
|
|
|
padding: '1rem',
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
cursor: submitting ? 'not-allowed' : 'pointer',
|
|
|
|
|
|
background: 'rgba(14,165,233,0.03)',
|
|
|
|
|
|
transition: 'border-color 0.15s',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Upload size={20} style={{ color: '#475569', marginBottom: '0.35rem' }} />
|
|
|
|
|
|
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
|
|
|
|
|
Drop files here or click to browse
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: '0.62rem', color: '#475569', marginTop: '0.2rem' }}>
|
|
|
|
|
|
Max 10 MB per file · PDF, PNG, JPG, DOC, XLSX, CSV, TXT, ZIP
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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(',')}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{errors.files && <div style={errorTextStyle}>{errors.files}</div>}
|
|
|
|
|
|
{files.length > 0 && (
|
|
|
|
|
|
<div style={{ marginTop: '0.5rem' }}>
|
|
|
|
|
|
{files.map((f, i) => (
|
|
|
|
|
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.3rem 0', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
|
|
|
|
|
|
<FileText size={13} style={{ color: '#64748B', flexShrink: 0 }} />
|
|
|
|
|
|
<span style={{ flex: 1, fontSize: '0.75rem', color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
|
|
|
|
|
<span style={{ fontSize: '0.68rem', color: '#475569', flexShrink: 0 }}>{formatSize(f.size)}</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={(e) => { e.stopPropagation(); removeFile(i); }}
|
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
|
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.15rem' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={12} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<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
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<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'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>,
|
|
|
|
|
|
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 }) {
|
|
|
|
|
|
const isCard = workflowType === 'CARD';
|
|
|
|
|
|
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}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
selected
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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' },
|
|
|
|
|
|
].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}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<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'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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' }} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-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
|
|
|
|
|
|
|
|
|
|
const applyState = (data) => {
|
|
|
|
|
|
setTotal(data.total ?? 0);
|
|
|
|
|
|
setFindings(data.findings || []);
|
|
|
|
|
|
setSyncedAt(data.synced_at || null);
|
|
|
|
|
|
setSyncStatus(data.sync_status || null);
|
|
|
|
|
|
setSyncError(data.error_message || null);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 12:23:05 -06:00
|
|
|
|
const fetchCounts = async () => {
|
|
|
|
|
|
setCountsLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${API_BASE}/ivanti/findings/counts`, { credentials: 'include' });
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (res.ok) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 });
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('Error loading status counts:', e);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setCountsLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-16 11:43:57 -06:00
|
|
|
|
const fetchFPWorkflowCounts = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${API_BASE}/ivanti/findings/fp-workflow-counts`, { credentials: 'include' });
|
|
|
|
|
|
const data = await res.json();
|
2026-03-16 12:13:13 -06:00
|
|
|
|
if (res.ok) setFPCounts({
|
|
|
|
|
|
findingCounts: data.findingCounts || {},
|
|
|
|
|
|
findingTotal: data.findingTotal || 0,
|
|
|
|
|
|
idCounts: data.idCounts || {},
|
|
|
|
|
|
idTotal: data.idTotal || 0,
|
|
|
|
|
|
});
|
2026-03-16 11:43:57 -06:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('Error loading FP workflow counts:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
const fetchFindings = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
2026-03-11 13:03:17 -06:00
|
|
|
|
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (res.ok) applyState(data);
|
|
|
|
|
|
} 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-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();
|
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-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-03-13 13:06:54 -06:00
|
|
|
|
let result = findings;
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}, [findings, 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
|
|
|
|
|
|
|
|
|
|
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(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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
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(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 11:56:56 -06:00
|
|
|
|
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 11:56:56 -06:00
|
|
|
|
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
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
</div>
|
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
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<StatusDonut
|
|
|
|
|
|
open={statusCounts.open}
|
|
|
|
|
|
closed={statusCounts.closed}
|
|
|
|
|
|
loading={countsLoading}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
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</span>}
|
2026-03-13 12:50:15 -06:00
|
|
|
|
</div>
|
2026-03-13 13:06:54 -06:00
|
|
|
|
<ActionCoverageDonut
|
|
|
|
|
|
findings={findings}
|
|
|
|
|
|
activeSegment={actionFilter}
|
|
|
|
|
|
onSegmentClick={(key) => {
|
|
|
|
|
|
setExcFilter(null);
|
|
|
|
|
|
setActionFilter(key);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-03-13 12:50:15 -06:00
|
|
|
|
</div>
|
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
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<FPWorkflowDonut counts={fpCounts.findingCounts} total={fpCounts.findingTotal} centerLabel="FINDINGS" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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
|
|
|
|
|
|
</div>
|
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
|
|
|
|
</div>
|
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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-02 10:12:04 -06:00
|
|
|
|
{/* ----------------------------------------------------------------
|
|
|
|
|
|
Panel 1.5 — Open vs Closed trend over time
|
|
|
|
|
|
---------------------------------------------------------------- */}
|
|
|
|
|
|
<IvantiCountsChart />
|
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
{/* ----------------------------------------------------------------
|
|
|
|
|
|
Panel 2 — Findings table
|
|
|
|
|
|
---------------------------------------------------------------- */}
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
|
|
|
|
|
border: '1px solid rgba(14,165,233,0.2)',
|
|
|
|
|
|
borderLeft: '3px solid #0EA5E9',
|
|
|
|
|
|
borderRadius: '0.5rem',
|
|
|
|
|
|
padding: '1.5rem',
|
|
|
|
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
|
|
|
|
|
|
}}>
|
2026-03-11 12:47:11 -06:00
|
|
|
|
{/* Panel header */}
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14,165,233,0.4)', margin: '0 0 4px 0' }}>
|
|
|
|
|
|
Host Findings
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<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)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
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>
|
|
|
|
|
|
</div>
|
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} ×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{/* 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} ×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{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
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
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' }} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{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}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
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}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
2026-03-11 12:47:11 -06:00
|
|
|
|
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
|
|
|
|
|
<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'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
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>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : 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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
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); }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
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)' }}>
|
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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</th>
|
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' }} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-03-11 12:47:11 -06:00
|
|
|
|
</span>
|
|
|
|
|
|
</th>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
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
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<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
|
|
|
|
>
|
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',
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</td>
|
2026-03-11 12:47:11 -06:00
|
|
|
|
{visibleCols.map((col) => (
|
2026-03-13 15:39:37 -06:00
|
|
|
|
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} />
|
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
|
|
|
|
</tr>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
{sorted.length === 0 && (
|
|
|
|
|
|
<tr>
|
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
|
|
|
|
<td colSpan={visibleCols.length + 1} 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
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
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}
|
|
|
|
|
|
findings={findings}
|
|
|
|
|
|
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}
|
|
|
|
|
|
canWrite={canWrite}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<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
|
|
|
|
/>
|
2026-03-11 11:47:03 -06:00
|
|
|
|
</div>
|
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
|
|
|
|
}
|