Add interactive report viewer to Exports page

Transform Exports page from download-only to interactive report workbench.
All export buttons now open a full-page ReportViewer with sort, filter,
inline edit, row deletion, and xlsx download of the curated data.

New features:
- ReportViewer component with multi-sheet tab support
- Atlas Commitment Dates report (overdue highlighting, hostname resolution from DB)
- Scan Type Coverage report (agent/network/mixed per host with summary bar)
- GET /api/atlas/commitments endpoint (JOINs atlas cache with findings for hostnames)
This commit is contained in:
Jordan Ramos
2026-08-18 14:58:27 -06:00
parent a178e5e772
commit 7484e81b9a
3 changed files with 1009 additions and 250 deletions

View File

@@ -168,6 +168,126 @@ function createAtlasRouter() {
}
});
/**
* GET /commitments
*
* Returns all Atlas action plans that have commitment dates, joined with
* hostname/IP/BU data from ivanti_findings (using override priority).
* One row per plan (not per host). Includes days_until_due calculation.
* Team scoping enforced by requireTeam().
*
* @returns {Object} 200 - { commitments: [...], total, overdue_count }
* @returns {Object} 500 - { error } on database failure
*/
router.get('/commitments', async (req, res) => {
try {
let query;
let params = [];
if (req.teamScope) {
const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
query = `
SELECT
a.host_id,
a.plans_json,
COALESCE(
(SELECT override_host_name FROM ivanti_findings WHERE host_id = a.host_id AND override_host_name IS NOT NULL LIMIT 1),
(SELECT host_name FROM ivanti_findings WHERE host_id = a.host_id AND host_name != '' LIMIT 1),
''
) AS hostname,
COALESCE(
(SELECT ip_address FROM ivanti_findings WHERE host_id = a.host_id AND ip_address != '' LIMIT 1),
''
) AS ip_address,
COALESCE(
(SELECT bu_ownership FROM ivanti_findings WHERE host_id = a.host_id LIMIT 1),
''
) AS bu_ownership,
(SELECT COUNT(*) FROM ivanti_findings WHERE host_id = a.host_id AND state = 'open')::int AS finding_count
FROM atlas_action_plans_cache a
INNER JOIN (
SELECT DISTINCT host_id FROM ivanti_findings
WHERE bu_ownership ILIKE ANY($1::text[])
) f ON a.host_id = f.host_id
WHERE a.has_action_plan = true
`;
params = [patterns];
} else {
query = `
SELECT
a.host_id,
a.plans_json,
COALESCE(
(SELECT override_host_name FROM ivanti_findings WHERE host_id = a.host_id AND override_host_name IS NOT NULL LIMIT 1),
(SELECT host_name FROM ivanti_findings WHERE host_id = a.host_id AND host_name != '' LIMIT 1),
''
) AS hostname,
COALESCE(
(SELECT ip_address FROM ivanti_findings WHERE host_id = a.host_id AND ip_address != '' LIMIT 1),
''
) AS ip_address,
COALESCE(
(SELECT bu_ownership FROM ivanti_findings WHERE host_id = a.host_id LIMIT 1),
''
) AS bu_ownership,
(SELECT COUNT(*) FROM ivanti_findings WHERE host_id = a.host_id AND state = 'open')::int AS finding_count
FROM atlas_action_plans_cache a
WHERE a.has_action_plan = true
`;
}
const { rows } = await pool.query(query, params);
// Expand each plan with a commit_date into a separate row
const today = new Date();
today.setHours(0, 0, 0, 0);
const commitments = [];
let overdueCount = 0;
for (const row of rows) {
let plans = [];
try { plans = JSON.parse(row.plans_json || '[]'); } catch (_) { continue; }
for (const plan of plans) {
if (!plan.commit_date) continue;
const commitDate = new Date(plan.commit_date);
commitDate.setHours(0, 0, 0, 0);
const diffMs = commitDate - today;
const daysUntilDue = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
const isOverdue = daysUntilDue < 0;
if (isOverdue) overdueCount++;
commitments.push({
host_id: row.host_id,
hostname: row.hostname || '',
ip_address: row.ip_address || '',
bu_ownership: row.bu_ownership || '',
plan_type: plan.plan_type || '',
commit_date: plan.commit_date,
status: plan.status || 'active',
days_until_due: daysUntilDue,
finding_count: row.finding_count || 0,
is_overdue: isOverdue,
});
}
}
// Sort by commit_date ascending (nearest deadlines first)
commitments.sort((a, b) => {
if (a.commit_date < b.commit_date) return -1;
if (a.commit_date > b.commit_date) return 1;
return 0;
});
res.json({ commitments, total: commitments.length, overdue_count: overdueCount });
} catch (err) {
console.error('[Atlas] Error fetching commitments:', err.message);
res.status(500).json({ error: 'Failed to fetch Atlas commitments.' });
}
});
/**
* POST /sync
*

View File

@@ -0,0 +1,483 @@
/**
* ReportViewer — Interactive report table with sort, filter, edit, and export.
*
* Renders fetched report data in a full-page overlay with interactive controls.
* All manipulations (edits, deletes, reordering) are client-side only — nothing persists to DB.
* Download exports the current visible/filtered state to xlsx.
*/
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { X, Download, Search, Trash2, ArrowUp, ArrowDown, ChevronLeft } from 'lucide-react';
import * as XLSX from 'xlsx';
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const OVERLAY = {
position: 'fixed', inset: 0, background: '#0F172A', zIndex: 9998,
display: 'flex', flexDirection: 'column', overflow: 'hidden',
};
const TOOLBAR = {
display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1.25rem',
borderBottom: '1px solid #334155', background: '#1E293B', flexWrap: 'wrap',
};
const TABLE_WRAP = {
flex: 1, overflow: 'auto', padding: '0 1rem 1rem',
};
const TH_STYLE = {
padding: '0.5rem 0.6rem', textAlign: 'left', fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.7rem', fontWeight: '600', color: '#94A3B8', whiteSpace: 'nowrap',
cursor: 'pointer', userSelect: 'none', position: 'sticky', top: 0,
background: '#1E293B', zIndex: 2, borderBottom: '1px solid #334155',
};
const FILTER_INPUT = {
background: '#0F172A', border: '1px solid #334155', borderRadius: '0.25rem',
color: '#E2E8F0', padding: '0.25rem 0.4rem', fontSize: '0.65rem', width: '100%',
fontFamily: "'JetBrains Mono', monospace", outline: 'none',
};
const CELL_STYLE = {
padding: '0.35rem 0.6rem', fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.7rem', color: '#E2E8F0', whiteSpace: 'nowrap',
overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '300px',
borderBottom: '1px solid rgba(51,65,85,0.4)',
};
const BTN = {
padding: '0.4rem 0.75rem', borderRadius: '0.375rem', border: 'none',
fontSize: '0.72rem', fontWeight: '600', cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: '0.3rem',
};
const TAB_STYLE = {
padding: '0.4rem 0.75rem', borderRadius: '0.375rem 0.375rem 0 0',
border: '1px solid #334155', borderBottom: 'none',
fontSize: '0.68rem', fontFamily: "'JetBrains Mono', monospace",
cursor: 'pointer', background: '#0F172A', color: '#94A3B8',
};
const TAB_ACTIVE = {
...TAB_STYLE, background: '#1E293B', color: '#E2E8F0', fontWeight: '600',
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function autoFit(ws, rows) {
if (!rows[0]) return;
ws['!cols'] = rows[0].map((_, ci) => ({
wch: Math.min(60, Math.max(10, ...rows.map(r => String(r[ci] ?? '').length)))
}));
}
function exportToXlsx(columns, rows, filename) {
const headers = columns.map(c => c.label);
const dataRows = rows.map(row => columns.map(c => row[c.id] ?? ''));
const aoa = [headers, ...dataRows];
const ws = XLSX.utils.aoa_to_sheet(aoa);
autoFit(ws, aoa);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Report');
XLSX.writeFile(wb, filename);
}
function exportMultiSheetXlsx(sheets, filename) {
const wb = XLSX.utils.book_new();
sheets.forEach(({ name, columns, rows }) => {
const headers = columns.map(c => c.label);
const dataRows = rows.map(row => columns.map(c => row[c.id] ?? ''));
const aoa = [headers, ...dataRows];
const ws = XLSX.utils.aoa_to_sheet(aoa);
autoFit(ws, aoa);
XLSX.utils.book_append_sheet(wb, ws, String(name || 'Sheet').slice(0, 31));
});
XLSX.writeFile(wb, filename);
}
function compareValues(a, b, type) {
if (a == null && b == null) return 0;
if (a == null) return -1;
if (b == null) return 1;
if (type === 'number') return Number(a) - Number(b);
if (type === 'date') return new Date(a) - new Date(b);
return String(a).localeCompare(String(b), undefined, { numeric: true });
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function ReportViewer({
isOpen, onClose, title, columns, data,
sheets, defaultSort, summaryBar, rowHighlight, filename,
}) {
// --- State ---
const [rows, setRows] = useState([]);
const [sortColumn, setSortColumn] = useState(defaultSort?.column || null);
const [sortDirection, setSortDirection] = useState(defaultSort?.direction || 'asc');
const [filters, setFilters] = useState({});
const [globalSearch, setGlobalSearch] = useState('');
const [selected, setSelected] = useState(new Set());
const [editingCell, setEditingCell] = useState(null);
const [editValue, setEditValue] = useState('');
const [activeSheet, setActiveSheet] = useState(0);
const editRef = useRef(null);
// Initialize rows from data/sheets
useEffect(() => {
if (!isOpen) return;
if (sheets && sheets.length > 0) {
setRows(sheets[activeSheet]?.data || []);
} else {
setRows(data || []);
}
setSelected(new Set());
setFilters({});
setGlobalSearch('');
setEditingCell(null);
}, [isOpen, data, sheets, activeSheet]);
// Reset sort when opening
useEffect(() => {
if (isOpen) {
setSortColumn(defaultSort?.column || null);
setSortDirection(defaultSort?.direction || 'asc');
}
}, [isOpen, defaultSort]);
// Focus edit input
useEffect(() => {
if (editRef.current) editRef.current.focus();
}, [editingCell]);
// Active columns (from sheets or props)
const activeColumns = useMemo(() => {
if (sheets && sheets.length > 0) {
return sheets[activeSheet]?.columns || columns;
}
return columns;
}, [sheets, activeSheet, columns]);
// --- Filtering ---
const filteredRows = useMemo(() => {
let result = rows;
// Per-column filters
Object.entries(filters).forEach(([colId, filterVal]) => {
if (!filterVal) return;
const lower = filterVal.toLowerCase();
result = result.filter(row => {
const cellVal = String(row[colId] ?? '').toLowerCase();
return cellVal.includes(lower);
});
});
// Global search
if (globalSearch) {
const lower = globalSearch.toLowerCase();
result = result.filter(row =>
activeColumns.some(col => String(row[col.id] ?? '').toLowerCase().includes(lower))
);
}
return result;
}, [rows, filters, globalSearch, activeColumns]);
// --- Sorting ---
const sortedRows = useMemo(() => {
if (!sortColumn) return filteredRows;
const col = activeColumns.find(c => c.id === sortColumn);
const type = col?.type || 'text';
const dir = sortDirection === 'asc' ? 1 : -1;
return [...filteredRows].sort((a, b) => dir * compareValues(a[sortColumn], b[sortColumn], type));
}, [filteredRows, sortColumn, sortDirection, activeColumns]);
// --- Handlers ---
const handleSort = useCallback((colId) => {
if (sortColumn === colId) {
if (sortDirection === 'asc') setSortDirection('desc');
else if (sortDirection === 'desc') { setSortColumn(null); setSortDirection('asc'); }
} else {
setSortColumn(colId);
setSortDirection('asc');
}
}, [sortColumn, sortDirection]);
const handleFilter = useCallback((colId, value) => {
setFilters(prev => ({ ...prev, [colId]: value }));
}, []);
const handleSelectAll = useCallback(() => {
if (selected.size === sortedRows.length) {
setSelected(new Set());
} else {
setSelected(new Set(sortedRows.map((_, i) => i)));
}
}, [selected, sortedRows]);
const handleSelectRow = useCallback((idx) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
}, []);
const handleDeleteSelected = useCallback(() => {
const selectedOriginalRows = sortedRows.filter((_, i) => selected.has(i));
setRows(prev => prev.filter(row => !selectedOriginalRows.includes(row)));
setSelected(new Set());
}, [selected, sortedRows]);
const handleCellClick = useCallback((rowIdx, colId, currentValue) => {
setEditingCell({ rowIdx, colId });
setEditValue(String(currentValue ?? ''));
}, []);
const commitEdit = useCallback(() => {
if (!editingCell) return;
const { rowIdx, colId } = editingCell;
const actualRow = sortedRows[rowIdx];
if (actualRow) {
setRows(prev => prev.map(row => {
if (row === actualRow) return { ...row, [colId]: editValue };
return row;
}));
}
setEditingCell(null);
}, [editingCell, editValue, sortedRows]);
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter') { commitEdit(); }
else if (e.key === 'Escape') { setEditingCell(null); }
}, [commitEdit]);
const handleDownload = useCallback(() => {
if (sheets && sheets.length > 0) {
// Multi-sheet: export all sheets with their current data (filters apply to active only)
const sheetData = sheets.map((s, idx) => ({
name: s.name,
columns: s.columns || columns,
rows: idx === activeSheet ? sortedRows : s.data,
}));
exportMultiSheetXlsx(sheetData, filename);
} else {
exportToXlsx(activeColumns, sortedRows, filename);
}
}, [sheets, columns, activeColumns, sortedRows, activeSheet, filename]);
const handleSheetChange = useCallback((idx) => {
setActiveSheet(idx);
setSelected(new Set());
setFilters({});
setGlobalSearch('');
setEditingCell(null);
}, []);
// --- Render ---
if (!isOpen) return null;
return (
<div style={OVERLAY}>
{/* Toolbar */}
<div style={TOOLBAR}>
<button
onClick={onClose}
style={{ ...BTN, background: '#334155', color: '#E2E8F0' }}
title="Back to exports"
>
<ChevronLeft style={{ width: '14px', height: '14px' }} />
Back
</button>
<h2 style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.85rem',
fontWeight: '700', color: '#E2E8F0', margin: 0, flex: 1,
}}>
{title}
</h2>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.68rem',
color: '#64748B',
}}>
{sortedRows.length !== rows.length
? `Showing ${sortedRows.length} of ${rows.length} rows`
: `${rows.length} rows`
}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', background: '#0F172A', border: '1px solid #334155', borderRadius: '0.375rem', padding: '0.3rem 0.5rem' }}>
<Search style={{ width: '12px', height: '12px', color: '#64748B' }} />
<input
style={{ background: 'none', border: 'none', color: '#E2E8F0', fontSize: '0.7rem', fontFamily: "'JetBrains Mono', monospace", outline: 'none', width: '160px' }}
placeholder="Search all columns..."
value={globalSearch}
onChange={e => setGlobalSearch(e.target.value)}
/>
{globalSearch && (
<button onClick={() => setGlobalSearch('')} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: 0 }}>
<X style={{ width: '10px', height: '10px' }} />
</button>
)}
</div>
{selected.size > 0 && (
<button
onClick={handleDeleteSelected}
style={{ ...BTN, background: 'rgba(239,68,68,0.15)', color: '#EF4444', border: '1px solid rgba(239,68,68,0.3)' }}
>
<Trash2 style={{ width: '12px', height: '12px' }} />
Delete ({selected.size})
</button>
)}
<button
onClick={handleDownload}
style={{ ...BTN, background: '#7C3AED', color: '#fff' }}
>
<Download style={{ width: '12px', height: '12px' }} />
Download .xlsx
</button>
</div>
{/* Summary bar */}
{summaryBar && (
<div style={{ padding: '0.5rem 1.25rem', borderBottom: '1px solid #334155', background: 'rgba(15,23,42,0.8)' }}>
{summaryBar}
</div>
)}
{/* Multi-sheet tabs */}
{sheets && sheets.length > 1 && (
<div style={{ display: 'flex', gap: '0.25rem', padding: '0.5rem 1.25rem 0', background: '#0F172A' }}>
{sheets.map((s, idx) => (
<button
key={idx}
onClick={() => handleSheetChange(idx)}
style={idx === activeSheet ? TAB_ACTIVE : TAB_STYLE}
>
{s.name}
</button>
))}
</div>
)}
{/* Table */}
<div style={TABLE_WRAP}>
{sortedRows.length === 0 && rows.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem', color: '#64748B', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8rem' }}>
No data found for this report.
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '0.5rem' }}>
<thead>
{/* Header row */}
<tr>
<th style={{ ...TH_STYLE, width: '36px', cursor: 'default' }}>
<input
type="checkbox"
checked={selected.size === sortedRows.length && sortedRows.length > 0}
onChange={handleSelectAll}
style={{ accentColor: '#7C3AED' }}
/>
</th>
{activeColumns.map(col => (
<th
key={col.id}
style={{ ...TH_STYLE, width: col.width || undefined }}
onClick={() => col.sortable !== false && handleSort(col.id)}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
{col.label}
{sortColumn === col.id && (
sortDirection === 'asc'
? <ArrowUp style={{ width: '10px', height: '10px', color: '#7C3AED' }} />
: <ArrowDown style={{ width: '10px', height: '10px', color: '#7C3AED' }} />
)}
</span>
</th>
))}
</tr>
{/* Filter row */}
<tr>
<th style={{ ...TH_STYLE, top: '32px', cursor: 'default' }}></th>
{activeColumns.map(col => (
<th key={`filter-${col.id}`} style={{ ...TH_STYLE, top: '32px', cursor: 'default', padding: '0.25rem 0.4rem' }}>
{col.filterable !== false && (
<input
style={FILTER_INPUT}
placeholder="Filter..."
value={filters[col.id] || ''}
onChange={e => handleFilter(col.id, e.target.value)}
/>
)}
</th>
))}
</tr>
</thead>
<tbody>
{sortedRows.map((row, rowIdx) => {
const highlight = rowHighlight ? rowHighlight(row) : null;
return (
<tr
key={rowIdx}
style={{
background: highlight || (selected.has(rowIdx) ? 'rgba(124,58,237,0.08)' : 'transparent'),
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!highlight && !selected.has(rowIdx)) e.currentTarget.style.background = 'rgba(14,165,233,0.04)'; }}
onMouseLeave={e => { if (!highlight && !selected.has(rowIdx)) e.currentTarget.style.background = 'transparent'; }}
>
<td style={{ ...CELL_STYLE, width: '36px', textAlign: 'center' }}>
<input
type="checkbox"
checked={selected.has(rowIdx)}
onChange={() => handleSelectRow(rowIdx)}
style={{ accentColor: '#7C3AED' }}
/>
</td>
{activeColumns.map(col => {
const isEditing = editingCell?.rowIdx === rowIdx && editingCell?.colId === col.id;
const value = row[col.id];
return (
<td
key={col.id}
style={{ ...CELL_STYLE, cursor: col.editable !== false ? 'pointer' : 'default' }}
onClick={() => col.editable !== false && !isEditing && handleCellClick(rowIdx, col.id, value)}
>
{isEditing ? (
<input
ref={editRef}
style={{ ...FILTER_INPUT, padding: '0.2rem 0.3rem' }}
value={editValue}
onChange={e => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitEdit}
/>
) : (
<span title={String(value ?? '')}>
{col.type === 'badge' && value ? (
<span style={{
padding: '0.15rem 0.4rem', borderRadius: '0.25rem',
fontSize: '0.62rem', fontWeight: '600',
background: value === 'agent' ? 'rgba(16,185,129,0.15)' : value === 'network' ? 'rgba(245,158,11,0.15)' : 'rgba(139,92,246,0.15)',
color: value === 'agent' ? '#10B981' : value === 'network' ? '#F59E0B' : '#A78BFA',
border: `1px solid ${value === 'agent' ? 'rgba(16,185,129,0.3)' : value === 'network' ? 'rgba(245,158,11,0.3)' : 'rgba(139,92,246,0.3)'}`,
}}>
{value}
</span>
) : (
String(value ?? '')
)}
</span>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import React, { useState, useCallback } from 'react';
import * as XLSX from 'xlsx';
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X, Wifi } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import AtlasIcon from '../AtlasIcon';
import ReportViewer from '../ReportViewer';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const EXC_PATTERN = /EXC-\d+/i;
@@ -18,53 +18,23 @@ function classifyFinding(f) {
const dateStr = () => new Date().toISOString().slice(0, 10);
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function autoFit(ws, rows) {
if (!rows[0]) return;
ws['!cols'] = rows[0].map((_, ci) => ({
wch: Math.min(60, Math.max(10, ...rows.map(r => String(r[ci] ?? '').length)))
/**
* Convert headers array to ReportViewer column definitions.
*/
function headersToColumns(headers, opts = {}) {
return headers.map(h => ({
id: h,
label: h,
sortable: true,
filterable: true,
editable: true,
type: (opts.numberCols || []).includes(h) ? 'number'
: (opts.dateCols || []).includes(h) ? 'date'
: (opts.badgeCols || []).includes(h) ? 'badge'
: 'text',
}));
}
function toXLSX(rows, sheetName, filename) {
const ws = XLSX.utils.aoa_to_sheet(rows);
autoFit(ws, rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, filename);
}
function toMultiXLSX(sheets, filename) {
const wb = XLSX.utils.book_new();
sheets.forEach(({ name, rows }) => {
const ws = XLSX.utils.aoa_to_sheet(rows);
autoFit(ws, rows);
XLSX.utils.book_append_sheet(wb, ws, String(name || 'Unknown').slice(0, 31));
});
XLSX.writeFile(wb, filename);
}
function toCSV(rows, filename) {
const csv = rows.map(row =>
row.map(cell => {
const s = String(cell ?? '');
return (s.includes(',') || s.includes('"') || s.includes('\n'))
? `"${s.replace(/"/g, '""')}"` : s;
}).join(',')
).join('\r\n');
triggerDownload(new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }), filename);
}
// ---------------------------------------------------------------------------
// Finding column definitions
// ---------------------------------------------------------------------------
@@ -74,24 +44,26 @@ const FINDING_HEADERS = [
'Business Unit', 'FP# ID', 'FP# State', 'Last Found', 'CVEs', 'Notes',
];
function findingRow(f) {
return [
f.id,
f.title,
f.severity != null ? Number(f.severity).toFixed(2) : '',
f.vrrGroup ?? '',
f.overrides?.hostName ?? f.hostName ?? '',
f.ipAddress ?? '',
f.overrides?.dns ?? f.dns ?? '',
f.dueDate ?? '',
f.slaStatus ?? '',
f.buOwnership ?? '',
f.workflow?.id ?? '',
f.workflow?.state ?? '',
f.lastFoundOn ?? '',
(f.cves || []).join(', '),
f.note ?? '',
];
const FINDING_COLUMNS = headersToColumns(FINDING_HEADERS, { numberCols: ['Severity Score'] });
function findingToObj(f) {
return {
'Finding ID': f.id,
'Title': f.title,
'Severity Score': f.severity != null ? Number(f.severity).toFixed(2) : '',
'Severity Group': f.vrrGroup ?? '',
'Host': f.overrides?.hostName ?? f.hostName ?? '',
'IP Address': f.ipAddress ?? '',
'DNS': f.overrides?.dns ?? f.dns ?? '',
'Due Date': f.dueDate ?? '',
'SLA Status': f.slaStatus ?? '',
'Business Unit': f.buOwnership ?? '',
'FP# ID': f.workflow?.id ?? '',
'FP# State': f.workflow?.state ?? '',
'Last Found': f.lastFoundOn ?? '',
'CVEs': (f.cves || []).join(', '),
'Notes': f.note ?? '',
};
}
// ---------------------------------------------------------------------------
@@ -132,6 +104,12 @@ async function fetchAtlasStatus() {
return res.json();
}
async function fetchAtlasCommitments() {
const res = await fetch(`${API_BASE}/atlas/commitments`, { credentials: 'include' });
if (!res.ok) throw new Error(`Atlas commitments returned ${res.status}`);
return res.json();
}
async function fetchJiraTickets() {
const res = await fetch(`${API_BASE}/jira-tickets`, { credentials: 'include' });
if (!res.ok) throw new Error(`Jira tickets returned ${res.status}`);
@@ -150,12 +128,6 @@ async function fetchCCPVerticals() {
return res.json();
}
async function fetchCCPMetrics() {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/metrics`, { credentials: 'include' });
if (!res.ok) throw new Error(`CCP metrics returned ${res.status}`);
return res.json();
}
async function fetchCCPTrend() {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/trend`, { credentials: 'include' });
if (!res.ok) throw new Error(`CCP trend returned ${res.status}`);
@@ -170,7 +142,6 @@ async function fetchCCPVerticalMetrics(code) {
async function fetchAtlasAndFindings(teamsParam) {
const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings(teamsParam)]);
// Build a lookup from hostId → finding details (hostname, IP, BU, etc.)
const hostMap = {};
findings.forEach(f => {
if (f.hostId && !hostMap[f.hostId]) {
@@ -187,6 +158,42 @@ async function fetchAtlasAndFindings(teamsParam) {
return { atlasRows, hostMap };
}
// ---------------------------------------------------------------------------
// Scan Type aggregation (client-side)
// ---------------------------------------------------------------------------
function aggregateScanTypes(findings) {
const hostMap = {};
findings.forEach(f => {
if (!f.hostId) return;
if (!hostMap[f.hostId]) {
hostMap[f.hostId] = {
'Host ID': f.hostId,
'Hostname': f.overrides?.hostName || f.hostName || '',
'IP Address': f.ipAddress || '',
'DNS': f.overrides?.dns || f.dns || '',
'Business Unit': f.buOwnership || '',
_agentCount: 0,
_networkCount: 0,
'Finding Count': 0,
'Highest Severity': 0,
};
}
const h = hostMap[f.hostId];
h['Finding Count']++;
if (f.scanType === 'agent') h._agentCount++;
else h._networkCount++;
const sev = parseFloat(f.severity) || 0;
if (sev > h['Highest Severity']) h['Highest Severity'] = sev;
});
return Object.values(hostMap).map(h => ({
...h,
'Scan Type': h._agentCount > 0 && h._networkCount > 0 ? 'mixed'
: h._agentCount > 0 ? 'agent' : 'network',
'Highest Severity': h['Highest Severity'] > 0 ? h['Highest Severity'].toFixed(2) : '',
}));
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
@@ -253,14 +260,14 @@ function ExportBtn({ label, exportKey, loading, color, colorRgb, onClick, disabl
);
}
function Toggle({ label, checked, onChange, color, colorRgb }) {
function Toggle({ label, checked, onChange, _color, colorRgb }) {
return (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
<div
onClick={() => onChange(!checked)}
style={{
width: '32px', height: '18px', borderRadius: '9px',
background: checked ? color : 'rgba(255,255,255,0.1)',
background: checked ? `rgba(${colorRgb},0.8)` : 'rgba(255,255,255,0.1)',
border: `1px solid rgba(${colorRgb},0.4)`,
position: 'relative', transition: 'background 0.2s',
cursor: 'pointer', flexShrink: 0,
@@ -289,6 +296,7 @@ export default function ExportsPage() {
const [error, setError] = useState(null);
const [cveStatus, setCveStatus] = useState('');
const [missingOnly, setMissingOnly] = useState(false);
const [activeReport, setActiveReport] = useState(null);
const run = useCallback(async (key, fn) => {
setLoading(key);
@@ -303,14 +311,25 @@ export default function ExportsPage() {
}
}, []);
/** Open ReportViewer with single-sheet data */
const openReport = useCallback((title, columns, data, filename, opts = {}) => {
setActiveReport({ title, columns, data, sheets: null, filename, ...opts });
}, []);
/** Open ReportViewer with multi-sheet data */
const openMultiReport = useCallback((title, sheets, filename, opts = {}) => {
setActiveReport({ title, columns: sheets[0]?.columns || [], data: [], sheets, filename, ...opts });
}, []);
// ---- Card 1: Ivanti Findings ----
const exportFullFindings = () => run('ivanti-full', async () => {
const findings = await fetchFindings(teamsParam);
const scopeLabel = teamsParam || 'ALL';
toXLSX(
[FINDING_HEADERS, ...findings.map(findingRow)],
'All Findings',
openReport(
'Ivanti Host Findings — Full Dump',
FINDING_COLUMNS,
findings.map(findingToObj),
`findings-full-${scopeLabel}-${dateStr()}.xlsx`,
);
});
@@ -318,8 +337,8 @@ export default function ExportsPage() {
const exportPending = () => run('ivanti-pending', async () => {
const findings = await fetchFindings(teamsParam);
const scopeLabel = teamsParam || 'ALL';
const rows = findings.filter(f => classifyFinding(f) === 'pending').map(findingRow);
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${scopeLabel}-${dateStr()}.xlsx`);
const rows = findings.filter(f => classifyFinding(f) === 'pending').map(findingToObj);
openReport('Ivanti Findings — Pending Action', FINDING_COLUMNS, rows, `findings-pending-${scopeLabel}-${dateStr()}.xlsx`);
});
const exportOverdue = () => run('ivanti-overdue', async () => {
@@ -329,8 +348,8 @@ export default function ExportsPage() {
const rows = findings.filter(f => {
if (!f.dueDate && !(f.slaStatus || '').toLowerCase().includes('overdue')) return false;
return f.dueDate < today || (f.slaStatus || '').toUpperCase() === 'OVERDUE';
}).map(findingRow);
toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${scopeLabel}-${dateStr()}.xlsx`);
}).map(findingToObj);
openReport('Ivanti Findings — Overdue SLA', FINDING_COLUMNS, rows, `findings-overdue-${scopeLabel}-${dateStr()}.xlsx`);
});
const exportByBU = () => run('ivanti-bu', async () => {
@@ -343,13 +362,15 @@ export default function ExportsPage() {
});
const sheets = Object.entries(groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, rows]) => ({ name, rows: [FINDING_HEADERS, ...rows.map(findingRow)] }));
if (sheets.length === 0) sheets.push({ name: 'No Data', rows: [FINDING_HEADERS] });
toMultiXLSX(sheets, `findings-by-bu-${dateStr()}.xlsx`);
.map(([name, rows]) => ({ name, columns: FINDING_COLUMNS, data: rows.map(findingToObj) }));
if (sheets.length === 0) sheets.push({ name: 'No Data', columns: FINDING_COLUMNS, data: [] });
openMultiReport('Ivanti Findings — By Business Unit', sheets, `findings-by-bu-${dateStr()}.xlsx`);
});
// ---- Card 2: FP Workflow Summary ----
const FP_COLUMNS = headersToColumns(['FP# ID', 'State', 'Finding Count', 'Hosts', 'Business Units', 'CVEs'], { numberCols: ['Finding Count'] });
const exportFPSummary = () => run('fp-summary', async () => {
const findings = await fetchFindings(teamsParam);
const fpMap = {};
@@ -363,121 +384,202 @@ export default function ExportsPage() {
if (f.buOwnership) fpMap[id].bus.add(f.buOwnership);
(f.cves || []).forEach(c => fpMap[id].cves.add(c));
});
const headers = ['FP# ID', 'State', 'Finding Count', 'Hosts', 'Business Units', 'CVEs'];
const rows = Object.values(fpMap)
.sort((a, b) => a.id.localeCompare(b.id))
.map(e => [e.id, e.state, e.count, [...e.hosts].join(', '), [...e.bus].join(', '), [...e.cves].join(', ')]);
toXLSX([headers, ...rows], 'FP Workflows', `fp-workflow-summary-${dateStr()}.xlsx`);
.map(e => ({
'FP# ID': e.id, 'State': e.state, 'Finding Count': e.count,
'Hosts': [...e.hosts].join(', '), 'Business Units': [...e.bus].join(', '),
'CVEs': [...e.cves].join(', '),
}));
openReport('FP Workflow Summary', FP_COLUMNS, rows, `fp-workflow-summary-${dateStr()}.xlsx`);
});
// ---- Card 3: CVE Database ----
const exportCVEs = (fmt) => run(`cves-${fmt}`, async () => {
const CVE_COLUMNS = headersToColumns(['CVE ID', 'Vendor', 'Severity', 'Status', 'Published Date', 'Description', 'Documents'], { numberCols: ['Severity', 'Documents'] });
const exportCVEs = () => run('cves-xlsx', async () => {
const data = await fetchCVEs(cveStatus);
const headers = ['CVE ID', 'Vendor', 'Severity', 'Status', 'Published Date', 'Description', 'Documents'];
const rows = data.map(c => [c.cve_id, c.vendor, c.severity, c.status, c.published_date ?? '', c.description ?? '', c.document_count ?? 0]);
if (fmt === 'csv') {
toCSV([headers, ...rows], `cve-database-${dateStr()}.csv`);
} else {
toXLSX([headers, ...rows], 'CVEs', `cve-database-${dateStr()}.xlsx`);
}
const rows = data.map(c => ({
'CVE ID': c.cve_id, 'Vendor': c.vendor, 'Severity': c.severity,
'Status': c.status, 'Published Date': c.published_date ?? '',
'Description': c.description ?? '', 'Documents': c.document_count ?? 0,
}));
openReport('CVE Database', CVE_COLUMNS, rows, `cve-database-${dateStr()}.xlsx`);
});
// ---- Card 4: Archer Tickets ----
const ARCHER_COLUMNS = headersToColumns(['EXC Number', 'Status', 'CVE ID', 'Vendor', 'Archer URL', 'Created']);
const exportArcher = () => run('archer', async () => {
const data = await fetchArcher();
const headers = ['EXC Number', 'Status', 'CVE ID', 'Vendor', 'Archer URL', 'Created'];
const rows = data.map(t => [t.exc_number, t.status, t.cve_id ?? '', t.vendor ?? '', t.archer_url ?? '', t.created_at ?? '']);
toXLSX([headers, ...rows], 'Archer Tickets', `archer-tickets-${dateStr()}.xlsx`);
const rows = data.map(t => ({
'EXC Number': t.exc_number, 'Status': t.status, 'CVE ID': t.cve_id ?? '',
'Vendor': t.vendor ?? '', 'Archer URL': t.archer_url ?? '', 'Created': t.created_at ?? '',
}));
openReport('Archer Risk Acceptance Tickets', ARCHER_COLUMNS, rows, `archer-tickets-${dateStr()}.xlsx`);
});
// ---- Card 5: Compliance Report ----
const COMPLIANCE_COLUMNS = headersToColumns(
['CVE ID', 'Vendor', 'Severity', 'Status', 'Total Docs', 'Advisory Docs', 'Email Docs', 'Screenshot Docs', 'Compliance Status'],
{ numberCols: ['Severity', 'Total Docs', 'Advisory Docs', 'Email Docs', 'Screenshot Docs'] }
);
const exportCompliance = () => run('compliance', async () => {
const data = await fetchCompliance();
const filtered = missingOnly ? data.filter(r => r.compliance_status !== 'Complete') : data;
const headers = ['CVE ID', 'Vendor', 'Severity', 'Status', 'Total Docs', 'Advisory Docs', 'Email Docs', 'Screenshot Docs', 'Compliance Status'];
const rows = filtered.map(r => [r.cve_id, r.vendor, r.severity, r.status, r.total_documents, r.advisory_count, r.email_count, r.screenshot_count, r.compliance_status]);
toXLSX([headers, ...rows], 'Compliance', `compliance-report-${dateStr()}.xlsx`);
const rows = filtered.map(r => ({
'CVE ID': r.cve_id, 'Vendor': r.vendor, 'Severity': r.severity, 'Status': r.status,
'Total Docs': r.total_documents, 'Advisory Docs': r.advisory_count,
'Email Docs': r.email_count, 'Screenshot Docs': r.screenshot_count,
'Compliance Status': r.compliance_status,
}));
openReport('Document Compliance Report', COMPLIANCE_COLUMNS, rows, `compliance-report-${dateStr()}.xlsx`);
});
// ---- Card 6: Atlas Action Plans ----
const ATLAS_HEADERS = ['Host ID', 'Hostname', 'IP Address', 'Business Unit', 'Open Findings', 'Active Plans', 'Plan Type', 'Commit Date', 'Status', 'Qualys ID', 'Findings ID', 'VNR', 'EXC', 'Last Synced'];
const ATLAS_HEADERS_ARR = ['Host ID', 'Hostname', 'IP Address', 'Business Unit', 'Open Findings', 'Active Plans', 'Plan Type', 'Commit Date', 'Status', 'Qualys ID', 'Findings ID', 'VNR', 'EXC', 'Last Synced'];
const ATLAS_COLUMNS = headersToColumns(ATLAS_HEADERS_ARR, { numberCols: ['Host ID', 'Open Findings', 'Active Plans'] });
function atlasRow(atlasEntry, hostInfo) {
const plans = JSON.parse(atlasEntry.plans_json || '[]');
const activePlans = plans.filter(p => p.status === 'active');
const h = hostInfo || {};
if (activePlans.length === 0) {
return [[
atlasEntry.host_id, h.hostName || '', h.ipAddress || '', h.buOwnership || '',
h.findingCount || '', 0, '', '', 'No Plan', '', '', '', '', atlasEntry.synced_at || '',
]];
}
return activePlans.map(p => [
atlasEntry.host_id, h.hostName || '', h.ipAddress || '', h.buOwnership || '',
h.findingCount || '', activePlans.length,
(p.plan_type || '').replace(/_/g, ' '), p.commit_date || '', p.status || '',
p.qualys_id || '', p.active_host_findings_id || '',
p.jira_vnr || '', p.archer_exc || '', atlasEntry.synced_at || '',
]);
function atlasToObjects(atlasRows, hostMap) {
const results = [];
atlasRows.forEach(a => {
const plans = JSON.parse(a.plans_json || '[]');
const activePlans = plans.filter(p => p.status === 'active');
const h = hostMap[a.host_id] || {};
if (activePlans.length === 0) {
results.push({
'Host ID': a.host_id, 'Hostname': h.hostName || '', 'IP Address': h.ipAddress || '',
'Business Unit': h.buOwnership || '', 'Open Findings': h.findingCount || '',
'Active Plans': 0, 'Plan Type': '', 'Commit Date': '', 'Status': 'No Plan',
'Qualys ID': '', 'Findings ID': '', 'VNR': '', 'EXC': '', 'Last Synced': a.synced_at || '',
});
} else {
activePlans.forEach(p => {
results.push({
'Host ID': a.host_id, 'Hostname': h.hostName || '', 'IP Address': h.ipAddress || '',
'Business Unit': h.buOwnership || '', 'Open Findings': h.findingCount || '',
'Active Plans': activePlans.length,
'Plan Type': (p.plan_type || '').replace(/_/g, ' '),
'Commit Date': p.commit_date || '', 'Status': p.status || '',
'Qualys ID': p.qualys_id || '', 'Findings ID': p.active_host_findings_id || '',
'VNR': p.jira_vnr || '', 'EXC': p.archer_exc || '', 'Last Synced': a.synced_at || '',
});
});
}
});
return results;
}
const exportAtlasStatus = () => run('atlas-status', async () => {
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
const rows = atlasRows.flatMap(a => atlasRow(a, hostMap[a.host_id]));
toXLSX([ATLAS_HEADERS, ...rows], 'Atlas Status', `atlas-action-plans-${dateStr()}.xlsx`);
const rows = atlasToObjects(atlasRows, hostMap);
openReport('Atlas Action Plans — Status', ATLAS_COLUMNS, rows, `atlas-action-plans-${dateStr()}.xlsx`);
});
const exportAtlasGaps = () => run('atlas-gaps', async () => {
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
const gaps = atlasRows.filter(a => !a.has_action_plan);
const rows = gaps.flatMap(a => atlasRow(a, hostMap[a.host_id]));
toXLSX([ATLAS_HEADERS, ...rows], 'Coverage Gaps', `atlas-coverage-gaps-${dateStr()}.xlsx`);
const rows = atlasToObjects(gaps, hostMap);
openReport('Atlas Action Plans — Coverage Gaps', ATLAS_COLUMNS, rows, `atlas-coverage-gaps-${dateStr()}.xlsx`);
});
const exportAtlasFull = () => run('atlas-full', async () => {
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
const withPlans = atlasRows.filter(a => a.has_action_plan);
const withoutPlans = atlasRows.filter(a => !a.has_action_plan);
const sheets = [
{ name: 'Active Plans', rows: [ATLAS_HEADERS, ...withPlans.flatMap(a => atlasRow(a, hostMap[a.host_id]))] },
{ name: 'No Plan', rows: [ATLAS_HEADERS, ...withoutPlans.flatMap(a => atlasRow(a, hostMap[a.host_id]))] },
];
// Add history sheet with inactive plans
const historyHeaders = ['Host ID', 'Hostname', 'Plan Type', 'Commit Date', 'Status', 'Qualys ID', 'Findings ID', 'VNR', 'EXC', 'Created'];
const historyColumns = headersToColumns(['Host ID', 'Hostname', 'Plan Type', 'Commit Date', 'Status', 'Qualys ID', 'Findings ID', 'VNR', 'EXC', 'Created']);
const historyRows = [];
atlasRows.forEach(a => {
const plans = JSON.parse(a.plans_json || '[]');
const inactive = plans.filter(p => p.status !== 'active');
const h = hostMap[a.host_id] || {};
inactive.forEach(p => {
historyRows.push([
a.host_id, h.hostName || '',
(p.plan_type || '').replace(/_/g, ' '), p.commit_date || '', p.status || '',
p.qualys_id || '', p.active_host_findings_id || '',
p.jira_vnr || '', p.archer_exc || '', p.created_at ? p.created_at.split('T')[0] : '',
]);
historyRows.push({
'Host ID': a.host_id, 'Hostname': h.hostName || '',
'Plan Type': (p.plan_type || '').replace(/_/g, ' '),
'Commit Date': p.commit_date || '', 'Status': p.status || '',
'Qualys ID': p.qualys_id || '', 'Findings ID': p.active_host_findings_id || '',
'VNR': p.jira_vnr || '', 'EXC': p.archer_exc || '',
'Created': p.created_at ? p.created_at.split('T')[0] : '',
});
});
});
sheets.push({ name: 'History', rows: [historyHeaders, ...historyRows] });
toMultiXLSX(sheets, `atlas-full-report-${dateStr()}.xlsx`);
const sheets = [
{ name: 'Active Plans', columns: ATLAS_COLUMNS, data: atlasToObjects(withPlans, hostMap) },
{ name: 'No Plan', columns: ATLAS_COLUMNS, data: atlasToObjects(withoutPlans, hostMap) },
{ name: 'History', columns: historyColumns, data: historyRows },
];
openMultiReport('Atlas Action Plans — Full Report', sheets, `atlas-full-report-${dateStr()}.xlsx`);
});
// ---- Card 6b: Atlas Commitment Dates (NEW) ----
const COMMITMENT_COLUMNS = headersToColumns(
['Host ID', 'Hostname', 'IP Address', 'Business Unit', 'Plan Type', 'Commitment Date', 'Status', 'Days Until Due', 'Finding Count'],
{ numberCols: ['Host ID', 'Days Until Due', 'Finding Count'], dateCols: ['Commitment Date'] }
);
const commitmentRowHighlight = (row) => {
const days = parseInt(row['Days Until Due'], 10);
if (isNaN(days)) return null;
if (days < 0) return 'rgba(239,68,68,0.1)';
if (days <= 7) return 'rgba(245,158,11,0.08)';
return null;
};
const exportAtlasCommitments = () => run('atlas-commitments', async () => {
const data = await fetchAtlasCommitments();
const rows = (data.commitments || []).map(c => ({
'Host ID': c.host_id,
'Hostname': c.hostname || '',
'IP Address': c.ip_address || '',
'Business Unit': c.bu_ownership || '',
'Plan Type': (c.plan_type || '').replace(/_/g, ' '),
'Commitment Date': c.commit_date || '',
'Status': c.status || '',
'Days Until Due': c.days_until_due,
'Finding Count': c.finding_count || 0,
}));
openReport(
'Atlas Action Plans — Commitment Dates',
COMMITMENT_COLUMNS,
rows,
`atlas-commitment-dates-${dateStr()}.xlsx`,
{
defaultSort: { column: 'Commitment Date', direction: 'asc' },
rowHighlight: commitmentRowHighlight,
summaryBar: (
<div style={{ display: 'flex', gap: '1.5rem', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.72rem' }}>
<span style={{ color: '#E2E8F0' }}>Total: <strong>{data.total || 0}</strong></span>
<span style={{ color: '#EF4444' }}>Overdue: <strong>{data.overdue_count || 0}</strong></span>
</div>
),
}
);
});
// ---- Card 7: Jira Tickets ----
const JIRA_COLUMNS = headersToColumns(['Ticket Key', 'CVE', 'Vendor', 'Summary', 'Status', 'Source', 'URL', 'Last Synced', 'Created']);
const jiraToObj = (t) => ({
'Ticket Key': t.ticket_key, 'CVE': t.cve_id, 'Vendor': t.vendor || '',
'Summary': t.summary || '', 'Status': t.status || 'Open',
'Source': t.source_context || 'cve', 'URL': t.url || '',
'Last Synced': t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never',
'Created': t.created_at ? new Date(t.created_at).toLocaleDateString() : '',
});
const exportJiraAll = () => run('jira-all', async () => {
const tickets = await fetchJiraTickets();
const headers = ['Ticket Key', 'CVE', 'Vendor', 'Summary', 'Status', 'Source', 'URL', 'Last Synced', 'Created'];
const rows = tickets.map(t => [
t.ticket_key, t.cve_id, t.vendor || '', t.summary || '', t.status || 'Open',
t.source_context || 'cve', t.url || '',
t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never',
t.created_at ? new Date(t.created_at).toLocaleDateString() : '',
]);
toXLSX([headers, ...rows], 'All Tickets', `jira-tickets-all-${dateStr()}.xlsx`);
openReport('Jira Tickets — All', JIRA_COLUMNS, tickets.map(jiraToObj), `jira-tickets-all-${dateStr()}.xlsx`);
});
const exportJiraOpen = () => run('jira-open', async () => {
@@ -487,14 +589,7 @@ export default function ExportsPage() {
const lower = (t.status || '').toLowerCase();
return !closedStatuses.some(s => lower.includes(s));
});
const headers = ['Ticket Key', 'CVE', 'Vendor', 'Summary', 'Status', 'Source', 'URL', 'Last Synced', 'Created'];
const rows = open.map(t => [
t.ticket_key, t.cve_id, t.vendor || '', t.summary || '', t.status || 'Open',
t.source_context || 'cve', t.url || '',
t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never',
t.created_at ? new Date(t.created_at).toLocaleDateString() : '',
]);
toXLSX([headers, ...rows], 'Open Tickets', `jira-tickets-open-${dateStr()}.xlsx`);
openReport('Jira Tickets — Open/Active', JIRA_COLUMNS, open.map(jiraToObj), `jira-tickets-open-${dateStr()}.xlsx`);
});
const exportJiraByCVE = () => run('jira-by-cve', async () => {
@@ -505,36 +600,38 @@ export default function ExportsPage() {
if (!groups[key]) groups[key] = [];
groups[key].push(t);
});
const headers = ['Ticket Key', 'Vendor', 'Summary', 'Status', 'Source', 'URL', 'Last Synced'];
const jiraByCVEColumns = headersToColumns(['Ticket Key', 'Vendor', 'Summary', 'Status', 'Source', 'URL', 'Last Synced']);
const sheets = Object.entries(groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([cve, tix]) => ({
name: cve.slice(0, 31),
rows: [headers, ...tix.map(t => [
t.ticket_key, t.vendor || '', t.summary || '', t.status || 'Open',
t.source_context || 'cve', t.url || '',
t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never',
])],
columns: jiraByCVEColumns,
data: tix.map(t => ({
'Ticket Key': t.ticket_key, 'Vendor': t.vendor || '', 'Summary': t.summary || '',
'Status': t.status || 'Open', 'Source': t.source_context || 'cve', 'URL': t.url || '',
'Last Synced': t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never',
})),
}));
if (sheets.length === 0) sheets.push({ name: 'No Data', rows: [headers] });
toMultiXLSX(sheets, `jira-tickets-by-cve-${dateStr()}.xlsx`);
if (sheets.length === 0) sheets.push({ name: 'No Data', columns: jiraByCVEColumns, data: [] });
openMultiReport('Jira Tickets — By CVE', sheets, `jira-tickets-by-cve-${dateStr()}.xlsx`);
});
// ---- Card 8: CCP Metrics ----
const CCP_SNAPSHOT_COLUMNS = headersToColumns(['Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %', 'Failing Metrics', 'Report Date'], { numberCols: ['Total Devices', 'Non-Compliant', 'Failing Metrics'] });
const exportCCPSnapshot = () => run('ccp-snapshot', async () => {
const stats = await fetchCCPStats();
const verticals = stats.verticals || [];
const headers = ['Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %', 'Failing Metrics', 'Report Date'];
const rows = verticals.map(v => [
v.vertical || v.code || '',
v.total_devices ?? v.totalDevices ?? '',
v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : (v.compliancePct != null ? `${Number(v.compliancePct).toFixed(1)}%` : ''),
v.failing_metrics ?? v.failingMetrics ?? '',
v.report_date ?? v.reportDate ?? '',
]);
toXLSX([headers, ...rows], 'CCP Snapshot', `ccp-compliance-snapshot-${dateStr()}.xlsx`);
const rows = verticals.map(v => ({
'Vertical': v.vertical || v.code || '',
'Total Devices': v.total_devices ?? v.totalDevices ?? '',
'Non-Compliant': v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
'Compliance %': v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : (v.compliancePct != null ? `${Number(v.compliancePct).toFixed(1)}%` : ''),
'Failing Metrics': v.failing_metrics ?? v.failingMetrics ?? '',
'Report Date': v.report_date ?? v.reportDate ?? '',
}));
openReport('CCP Compliance Metrics — Snapshot', CCP_SNAPSHOT_COLUMNS, rows, `ccp-compliance-snapshot-${dateStr()}.xlsx`);
});
const exportCCPNonCompliant = () => run('ccp-noncompliant', async () => {
@@ -549,37 +646,41 @@ export default function ExportsPage() {
metricList.forEach(m => {
const devices = m.devices || [];
devices.forEach(d => {
allRows.push([
code, m.metric_id || m.metricId || '', m.metric_desc || m.metricDesc || '',
d.hostname || '', d.ip_address || d.ipAddress || '', d.device_type || d.deviceType || '',
d.team || '',
]);
allRows.push({
'Vertical': code,
'Metric ID': m.metric_id || m.metricId || '',
'Metric Description': m.metric_desc || m.metricDesc || '',
'Hostname': d.hostname || '',
'IP Address': d.ip_address || d.ipAddress || '',
'Device Type': d.device_type || d.deviceType || '',
'Team': d.team || '',
});
});
});
} catch (e) {
} catch (_e) {
// Skip verticals that fail
}
}
const headers = ['Vertical', 'Metric ID', 'Metric Description', 'Hostname', 'IP Address', 'Device Type', 'Team'];
toXLSX([headers, ...allRows], 'Non-Compliant Devices', `ccp-non-compliant-devices-${dateStr()}.xlsx`);
const columns = headersToColumns(['Vertical', 'Metric ID', 'Metric Description', 'Hostname', 'IP Address', 'Device Type', 'Team']);
openReport('CCP — Non-Compliant Devices', columns, allRows, `ccp-non-compliant-devices-${dateStr()}.xlsx`);
});
const exportCCPTrend = () => run('ccp-trend', async () => {
const trend = await fetchCCPTrend();
const snapshots = trend.snapshots || trend || [];
const headers = ['Date', 'Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %'];
const rows = snapshots.flatMap(s => {
const date = s.report_date || s.reportDate || s.date || '';
const verts = s.verticals || [s];
return verts.map(v => [
date,
v.vertical || v.code || '',
v.total_devices ?? v.totalDevices ?? '',
v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : '',
]);
return verts.map(v => ({
'Date': date,
'Vertical': v.vertical || v.code || '',
'Total Devices': v.total_devices ?? v.totalDevices ?? '',
'Non-Compliant': v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
'Compliance %': v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : '',
}));
});
toXLSX([headers, ...rows], 'Trend', `ccp-compliance-trend-${dateStr()}.xlsx`);
const columns = headersToColumns(['Date', 'Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %'], { numberCols: ['Total Devices', 'Non-Compliant'] });
openReport('CCP Compliance — Trend History', columns, rows, `ccp-compliance-trend-${dateStr()}.xlsx`);
});
const exportCCPFull = () => run('ccp-full', async () => {
@@ -587,38 +688,42 @@ export default function ExportsPage() {
const verticals = stats.verticals || [];
const snapshots = trend.snapshots || trend || [];
// Sheet 1: Summary
const summaryHeaders = ['Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %', 'Failing Metrics', 'Report Date'];
const summaryRows = verticals.map(v => [
v.vertical || v.code || '',
v.total_devices ?? v.totalDevices ?? '',
v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : '',
v.failing_metrics ?? v.failingMetrics ?? '',
v.report_date ?? v.reportDate ?? '',
]);
const summaryColumns = CCP_SNAPSHOT_COLUMNS;
const summaryRows = verticals.map(v => ({
'Vertical': v.vertical || v.code || '',
'Total Devices': v.total_devices ?? v.totalDevices ?? '',
'Non-Compliant': v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
'Compliance %': v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : '',
'Failing Metrics': v.failing_metrics ?? v.failingMetrics ?? '',
'Report Date': v.report_date ?? v.reportDate ?? '',
}));
// Sheet 2: Trend
const trendHeaders = ['Date', 'Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %'];
const trendColumns = headersToColumns(['Date', 'Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %'], { numberCols: ['Total Devices', 'Non-Compliant'] });
const trendRows = snapshots.flatMap(s => {
const date = s.report_date || s.reportDate || s.date || '';
const verts = s.verticals || [s];
return verts.map(v => [
date, v.vertical || v.code || '',
v.total_devices ?? v.totalDevices ?? '',
v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : '',
]);
return verts.map(v => ({
'Date': date, 'Vertical': v.vertical || v.code || '',
'Total Devices': v.total_devices ?? v.totalDevices ?? '',
'Non-Compliant': v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
'Compliance %': v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : '',
}));
});
toMultiXLSX([
{ name: 'Summary', rows: [summaryHeaders, ...summaryRows] },
{ name: 'Trend', rows: [trendHeaders, ...trendRows] },
openMultiReport('CCP Compliance — Full Report', [
{ name: 'Summary', columns: summaryColumns, data: summaryRows },
{ name: 'Trend', columns: trendColumns, data: trendRows },
], `ccp-full-report-${dateStr()}.xlsx`);
});
// ---- Card 9: Remediation Status (Cross-Domain) ----
const REMEDIATION_COLUMNS = headersToColumns([
'CVE ID', 'Vendor', 'Severity', 'CVE Status',
'Jira Tickets', 'Jira Statuses', 'Archer EXC#', 'Archer Status',
'Ivanti Findings', 'Overdue Findings', 'Overall Progress',
], { numberCols: ['Severity', 'Ivanti Findings', 'Overdue Findings'] });
const exportRemediationStatus = () => run('remediation', async () => {
const [cves, tickets, archer, findings] = await Promise.all([
fetchCVEs(''),
@@ -627,21 +732,18 @@ export default function ExportsPage() {
fetchFindings(teamsParam),
]);
// Build lookup maps
const ticketsByCVE = {};
tickets.forEach(t => {
const key = `${t.cve_id}|${t.vendor || ''}`;
if (!ticketsByCVE[key]) ticketsByCVE[key] = [];
ticketsByCVE[key].push(t);
});
const archerByCVE = {};
archer.forEach(a => {
const key = `${a.cve_id}|${a.vendor || ''}`;
if (!archerByCVE[key]) archerByCVE[key] = [];
archerByCVE[key].push(a);
});
const findingsByCVE = {};
findings.forEach(f => {
(f.cves || []).forEach(cve => {
@@ -650,14 +752,6 @@ export default function ExportsPage() {
});
});
const headers = [
'CVE ID', 'Vendor', 'Severity', 'CVE Status',
'Jira Tickets', 'Jira Statuses',
'Archer EXC#', 'Archer Status',
'Ivanti Findings', 'Overdue Findings',
'Overall Progress',
];
const rows = cves.map(c => {
const key = `${c.cve_id}|${c.vendor}`;
const cveTickets = ticketsByCVE[key] || [];
@@ -666,32 +760,67 @@ export default function ExportsPage() {
const today = dateStr();
const overdueCount = cveFindings.filter(f => f.dueDate && f.dueDate < today).length;
// Determine overall progress
let progress = 'Not Started';
if (cveTickets.length > 0 || cveArcher.length > 0) {
const closedKeywords = ['closed', 'done', 'resolved', 'complete', 'completed'];
const allTicketsClosed = cveTickets.length > 0 && cveTickets.every(t => closedKeywords.some(s => (t.status || '').toLowerCase().includes(s)));
const allArcherAccepted = cveArcher.length > 0 && cveArcher.every(a => a.status === 'Accepted');
if (allTicketsClosed && (cveArcher.length === 0 || allArcherAccepted)) {
progress = 'Complete';
} else {
progress = 'In Progress';
}
if (allTicketsClosed && (cveArcher.length === 0 || allArcherAccepted)) progress = 'Complete';
else progress = 'In Progress';
}
return [
c.cve_id, c.vendor, c.severity, c.status,
cveTickets.map(t => t.ticket_key).join(', '),
cveTickets.map(t => `${t.ticket_key}: ${t.status || 'Open'}`).join('; '),
cveArcher.map(a => a.exc_number).join(', '),
cveArcher.map(a => `${a.exc_number}: ${a.status}`).join('; '),
cveFindings.length,
overdueCount,
progress,
];
return {
'CVE ID': c.cve_id, 'Vendor': c.vendor, 'Severity': c.severity, 'CVE Status': c.status,
'Jira Tickets': cveTickets.map(t => t.ticket_key).join(', '),
'Jira Statuses': cveTickets.map(t => `${t.ticket_key}: ${t.status || 'Open'}`).join('; '),
'Archer EXC#': cveArcher.map(a => a.exc_number).join(', '),
'Archer Status': cveArcher.map(a => `${a.exc_number}: ${a.status}`).join('; '),
'Ivanti Findings': cveFindings.length,
'Overdue Findings': overdueCount,
'Overall Progress': progress,
};
});
openReport('Remediation Status Report', REMEDIATION_COLUMNS, rows, `remediation-status-${dateStr()}.xlsx`);
});
toXLSX([headers, ...rows], 'Remediation Status', `remediation-status-${dateStr()}.xlsx`);
// ---- Card 10: Scan Type Coverage (NEW) ----
const SCAN_TYPE_COLUMNS = headersToColumns(
['Host ID', 'Hostname', 'IP Address', 'DNS', 'Business Unit', 'Scan Type', 'Finding Count', 'Highest Severity'],
{ numberCols: ['Host ID', 'Finding Count', 'Highest Severity'], badgeCols: ['Scan Type'] }
);
const scanTypeRowHighlight = (row) => {
if (row['Scan Type'] === 'network') return 'rgba(245,158,11,0.06)';
return null;
};
const exportScanTypes = () => run('scan-types', async () => {
const findings = await fetchFindings(teamsParam);
const rows = aggregateScanTypes(findings);
const totalHosts = rows.length;
const agentCount = rows.filter(r => r['Scan Type'] === 'agent').length;
const networkCount = rows.filter(r => r['Scan Type'] === 'network').length;
const mixedCount = rows.filter(r => r['Scan Type'] === 'mixed').length;
const agentPct = totalHosts > 0 ? ((agentCount / totalHosts) * 100).toFixed(1) : '0.0';
openReport(
'Scan Type Coverage',
SCAN_TYPE_COLUMNS,
rows,
`scan-type-coverage-${dateStr()}.xlsx`,
{
rowHighlight: scanTypeRowHighlight,
summaryBar: (
<div style={{ display: 'flex', gap: '1.5rem', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.72rem' }}>
<span style={{ color: '#E2E8F0' }}>Total Hosts: <strong>{totalHosts}</strong></span>
<span style={{ color: '#10B981' }}>Agent: <strong>{agentCount}</strong> ({agentPct}%)</span>
<span style={{ color: '#F59E0B' }}>Network: <strong>{networkCount}</strong></span>
<span style={{ color: '#A78BFA' }}>Mixed: <strong>{mixedCount}</strong></span>
</div>
),
}
);
});
// ---- Render ----
@@ -705,6 +834,24 @@ export default function ExportsPage() {
);
}
// Show ReportViewer when a report is active
if (activeReport) {
return (
<ReportViewer
isOpen={true}
onClose={() => setActiveReport(null)}
title={activeReport.title}
columns={activeReport.columns}
data={activeReport.data}
sheets={activeReport.sheets}
defaultSort={activeReport.defaultSort || null}
summaryBar={activeReport.summaryBar || null}
rowHighlight={activeReport.rowHighlight || null}
filename={activeReport.filename}
/>
);
}
return (
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
@@ -760,7 +907,7 @@ export default function ExportsPage() {
title="FP Workflow Summary"
description="One row per unique FP# ticket ID. Shows state, how many findings belong to that ticket, which hosts are affected, and which CVEs are involved. Use this for status meetings."
>
<ExportBtn label="Export FP Summary (.xlsx)" exportKey="fp-summary" loading={loading} color="#0EA5E9" colorRgb="14,165,233" onClick={exportFPSummary} />
<ExportBtn label="FP Summary" exportKey="fp-summary" loading={loading} color="#0EA5E9" colorRgb="14,165,233" onClick={exportFPSummary} />
</ExportCard>
{/* ── Card 3: CVE Database ── */}
@@ -790,10 +937,7 @@ export default function ExportsPage() {
<option value="Resolved">Resolved</option>
</select>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<ExportBtn label="Export CSV" exportKey="cves-csv" loading={loading} color="#22C55E" colorRgb="34,197,94" onClick={() => exportCVEs('csv')} />
<ExportBtn label="Export .xlsx" exportKey="cves-xlsx" loading={loading} color="#22C55E" colorRgb="34,197,94" onClick={() => exportCVEs('xlsx')} />
</div>
<ExportBtn label="View CVE Report" exportKey="cves-xlsx" loading={loading} color="#22C55E" colorRgb="34,197,94" onClick={exportCVEs} />
</div>
</ExportCard>
@@ -804,7 +948,7 @@ export default function ExportsPage() {
title="Archer Risk Acceptance Tickets"
description="Export all Archer EXC exception tickets with their linked CVE IDs, vendors, statuses, and Archer URLs. Useful for risk acceptance reporting and audits."
>
<ExportBtn label="Export Archer Tickets (.xlsx)" exportKey="archer" loading={loading} color="#F97316" colorRgb="249,115,22" onClick={exportArcher} />
<ExportBtn label="Archer Tickets" exportKey="archer" loading={loading} color="#F97316" colorRgb="249,115,22" onClick={exportArcher} />
</ExportCard>
{/* ── Card 5: Compliance Report ── */}
@@ -819,10 +963,10 @@ export default function ExportsPage() {
label="Missing required docs only"
checked={missingOnly}
onChange={setMissingOnly}
color="#EF4444"
_color="#EF4444"
colorRgb="239,68,68"
/>
<ExportBtn label="Export Compliance Report (.xlsx)" exportKey="compliance" loading={loading} color="#EF4444" colorRgb="239,68,68" onClick={exportCompliance} />
<ExportBtn label="Compliance Report" exportKey="compliance" loading={loading} color="#EF4444" colorRgb="239,68,68" onClick={exportCompliance} />
</div>
</ExportCard>
@@ -831,17 +975,16 @@ export default function ExportsPage() {
color="#A855F7" colorRgb="168,85,247"
icon={AtlasIcon}
title="Atlas Action Plans"
description="Export Atlas InfoSec action plan status for all synced hosts. Includes plan type, commit date, and coverage status. Three report types: full status, coverage gaps only, and a multi-sheet workbook with active plans, gaps, and plan history."
description="Export Atlas InfoSec action plan status for all synced hosts. Includes plan type, commit date, and coverage status. Four report types: full status, coverage gaps, commitment date tracker, and a combined multi-sheet workbook."
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
<ExportBtn label="Full Status" exportKey="atlas-status" loading={loading} color="#A855F7" colorRgb="168,85,247" onClick={exportAtlasStatus} />
<ExportBtn label="Coverage Gaps" exportKey="atlas-gaps" loading={loading} color="#A855F7" colorRgb="168,85,247" onClick={exportAtlasGaps} />
</div>
<div style={{ marginTop: '0.5rem' }}>
<ExportBtn label="Full Report (multi-sheet)" exportKey="atlas-full" loading={loading} color="#A855F7" colorRgb="168,85,247" onClick={exportAtlasFull} />
<ExportBtn label="Commitment Dates" exportKey="atlas-commitments" loading={loading} color="#A855F7" colorRgb="168,85,247" onClick={exportAtlasCommitments} />
<ExportBtn label="Full Report" exportKey="atlas-full" loading={loading} color="#A855F7" colorRgb="168,85,247" onClick={exportAtlasFull} />
</div>
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
"Full Report" creates three sheets: Active Plans, No Plan, and History (overridden plans).
"Commitment Dates" shows plans sorted by due date with overdue highlighting. "Full Report" creates three sheets: Active Plans, No Plan, and History.
</p>
</ExportCard>
@@ -886,12 +1029,25 @@ export default function ExportsPage() {
title="Remediation Status Report"
description="Cross-domain view combining CVE entries, linked Jira tickets, Archer exceptions, and Ivanti findings into a single per-CVE/vendor row. Shows overall progress (Not Started, In Progress, Complete) based on ticket and exception statuses."
>
<ExportBtn label="Export Remediation Status (.xlsx)" exportKey="remediation" loading={loading} color="#EC4899" colorRgb="236,72,153" onClick={exportRemediationStatus} />
<ExportBtn label="Remediation Status" exportKey="remediation" loading={loading} color="#EC4899" colorRgb="236,72,153" onClick={exportRemediationStatus} />
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
Pulls from CVE database, Jira tickets, Archer tickets, and Ivanti findings cache. Best for leadership status updates.
</p>
</ExportCard>
{/* ── Card 10: Scan Type Coverage (NEW) ── */}
<ExportCard
color="#06B6D4" colorRgb="6,182,212"
icon={Wifi}
title="Scan Type Coverage"
description="Shows which assets have a Qualys Cloud Agent (authenticated scanning) versus network-only appliance scanning. Use this to identify coverage gaps and prioritize agent deployment."
>
<ExportBtn label="Scan Type Report" exportKey="scan-types" loading={loading} color="#06B6D4" colorRgb="6,182,212" onClick={exportScanTypes} />
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
Aggregates per unique host. Shows agent/network/mixed with a coverage summary bar.
</p>
</ExportCard>
</div>
</div>
);