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:
@@ -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
|
||||
*
|
||||
|
||||
483
frontend/src/components/ReportViewer.js
Normal file
483
frontend/src/components/ReportViewer.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user