Compare commits
10 Commits
feature/re
...
9893460b64
| Author | SHA1 | Date | |
|---|---|---|---|
| 9893460b64 | |||
| 51b1f99b3a | |||
| 669396f635 | |||
| 8b3ea22fa0 | |||
| 75b8ecc61d | |||
| ade3cc25ad | |||
| 3fd6158eb3 | |||
| 5bbaaf5918 | |||
| 1f36d302ea | |||
| 8697ba4ef3 |
@@ -120,6 +120,16 @@ function initTables(db) {
|
||||
// Extract only the fields we need from a raw finding object
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractFinding(f) {
|
||||
// statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part
|
||||
const rawDueDate = f.statusEmbedded?.dueDate || '';
|
||||
const dueDate = rawDueDate ? rawDueDate.split('T')[0] : '';
|
||||
|
||||
// BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"]
|
||||
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || '';
|
||||
|
||||
// CVE list: vulnerabilities.vulnInfoList[].cve
|
||||
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
|
||||
|
||||
return {
|
||||
id: String(f.id),
|
||||
title: f.title || '',
|
||||
@@ -130,11 +140,10 @@ function extractFinding(f) {
|
||||
dns: f.dns || f.host?.fqdn || '',
|
||||
status: f.status || '',
|
||||
slaStatus: f.slaStatus || '',
|
||||
discoveredOn: f.discoveredOn || '',
|
||||
dueDate,
|
||||
lastFoundOn: f.lastFoundOn || '',
|
||||
source: f.scannerPrettyName || f.scannerName || f.source || '',
|
||||
pluginFamily: f.pluginFamily || '',
|
||||
findingType: f.findingType || ''
|
||||
buOwnership,
|
||||
cves
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import NvdSyncModal from './components/NvdSyncModal';
|
||||
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
|
||||
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
||||
import NavDrawer from './components/NavDrawer';
|
||||
import CalendarWidget from './components/CalendarWidget';
|
||||
import ReportingPage from './components/pages/ReportingPage';
|
||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||
import ExportsPage from './components/pages/ExportsPage';
|
||||
@@ -177,6 +178,7 @@ export default function App() {
|
||||
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState('home');
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [calendarFilter, setCalendarFilter] = useState(null);
|
||||
const [showAddCVE, setShowAddCVE] = useState(false);
|
||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||
@@ -960,12 +962,16 @@ export default function App() {
|
||||
isOpen={navOpen}
|
||||
onClose={() => setNavOpen(false)}
|
||||
currentPage={currentPage}
|
||||
onNavigate={setCurrentPage}
|
||||
onNavigate={(page) => {
|
||||
// Clear calendar filter when navigating directly via the nav drawer
|
||||
if (page === 'reporting') setCalendarFilter(null);
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
/>
|
||||
{/* Scanning line effect */}
|
||||
<div className="scan-line"></div>
|
||||
|
||||
<div className="max-w-7xl mx-auto relative z-10">
|
||||
<div className={`${currentPage === 'reporting' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
@@ -1035,7 +1041,7 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
{currentPage === 'reporting' && <ReportingPage />}
|
||||
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} />}
|
||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||
{currentPage === 'exports' && <ExportsPage />}
|
||||
|
||||
@@ -2219,63 +2225,12 @@ export default function App() {
|
||||
Calendar
|
||||
</h2>
|
||||
|
||||
{/* Simple Calendar Grid */}
|
||||
<div className="mb-2">
|
||||
<div className="text-center mb-3">
|
||||
<span className="text-white font-semibold font-mono">February 2024</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-xs mb-2">
|
||||
<div className="text-gray-400 font-mono">Su</div>
|
||||
<div className="text-gray-400 font-mono">Mo</div>
|
||||
<div className="text-gray-400 font-mono">Tu</div>
|
||||
<div className="text-gray-400 font-mono">We</div>
|
||||
<div className="text-gray-400 font-mono">Th</div>
|
||||
<div className="text-gray-400 font-mono">Fr</div>
|
||||
<div className="text-gray-400 font-mono">Sa</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 text-center">
|
||||
{/* Week 1 */}
|
||||
<div className="text-gray-600 font-mono text-xs p-1">28</div>
|
||||
<div className="text-gray-600 font-mono text-xs p-1">29</div>
|
||||
<div className="text-gray-600 font-mono text-xs p-1">30</div>
|
||||
<div className="text-gray-600 font-mono text-xs p-1">31</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">1</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">2</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">3</div>
|
||||
{/* Week 2 */}
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">4</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">5</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">6</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">7</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">8</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">9</div>
|
||||
<div className="bg-intel-accent/30 text-white font-mono text-xs p-1 rounded font-bold border border-intel-accent">10</div>
|
||||
{/* Week 3 */}
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">11</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">12</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">13</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">14</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">15</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">16</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">17</div>
|
||||
{/* Week 4 */}
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">18</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">19</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">20</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">21</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">22</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">23</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">24</div>
|
||||
{/* Week 5 */}
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">25</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">26</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">27</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">28</div>
|
||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">29</div>
|
||||
<div className="text-gray-600 font-mono text-xs p-1">1</div>
|
||||
<div className="text-gray-600 font-mono text-xs p-1">2</div>
|
||||
</div>
|
||||
</div>
|
||||
<CalendarWidget
|
||||
onDateClick={(dateStr) => {
|
||||
setCalendarFilter(dateStr);
|
||||
setCurrentPage('reporting');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Open Vendor Tickets */}
|
||||
|
||||
167
frontend/src/components/CalendarWidget.js
Normal file
167
frontend/src/components/CalendarWidget.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
const DAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
|
||||
function toLocalDateStr(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export default function CalendarWidget({ onDateClick }) {
|
||||
const today = new Date();
|
||||
const todayStr = toLocalDateStr(today);
|
||||
|
||||
const [calYear, setCalYear] = useState(today.getFullYear());
|
||||
const [calMonth, setCalMonth] = useState(today.getMonth()); // 0-indexed
|
||||
|
||||
// Map of "YYYY-MM-DD" → count of findings due that day
|
||||
const [dueDates, setDueDates] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (!data?.findings) return;
|
||||
const counts = {};
|
||||
data.findings.forEach((f) => {
|
||||
if (f.dueDate) {
|
||||
counts[f.dueDate] = (counts[f.dueDate] || 0) + 1;
|
||||
}
|
||||
});
|
||||
setDueDates(counts);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const prevMonth = () => {
|
||||
if (calMonth === 0) { setCalMonth(11); setCalYear((y) => y - 1); }
|
||||
else { setCalMonth((m) => m - 1); }
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
if (calMonth === 11) { setCalMonth(0); setCalYear((y) => y + 1); }
|
||||
else { setCalMonth((m) => m + 1); }
|
||||
};
|
||||
|
||||
// Build cell array: null = padding, number = day of month
|
||||
const firstDow = new Date(calYear, calMonth, 1).getDay(); // 0=Sun
|
||||
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
||||
const cells = [
|
||||
...Array(firstDow).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
while (cells.length % 7 !== 0) cells.push(null); // complete last row
|
||||
|
||||
const hasDueDatesThisMonth = cells.some((day) => {
|
||||
if (!day) return false;
|
||||
const ds = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
return !!dueDates[ds];
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Month navigation */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||
>
|
||||
<ChevronLeft style={{ width: '14px', height: '14px' }} />
|
||||
</button>
|
||||
|
||||
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontWeight: '600', fontSize: '0.85rem' }}>
|
||||
{MONTH_NAMES[calMonth]} {calYear}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||
>
|
||||
<ChevronRight style={{ width: '14px', height: '14px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day-of-week headers */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', textAlign: 'center', marginBottom: '4px' }}>
|
||||
{DAY_NAMES.map((d) => (
|
||||
<div key={d} style={{ fontSize: '0.6rem', color: '#475569', fontFamily: 'monospace', fontWeight: '600', textTransform: 'uppercase' }}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
|
||||
{cells.map((day, idx) => {
|
||||
if (!day) return <div key={idx} />;
|
||||
|
||||
const dateStr = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const isToday = dateStr === todayStr;
|
||||
const dueCount = dueDates[dateStr] || 0;
|
||||
const hasDue = dueCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
title={hasDue ? `${dueCount} finding${dueCount > 1 ? 's' : ''} due — click to view` : undefined}
|
||||
onClick={hasDue && onDateClick ? () => onDateClick(dateStr) : undefined}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '2px', padding: '3px 1px',
|
||||
borderRadius: '4px',
|
||||
background: isToday ? 'rgba(14,165,233,0.2)' : 'transparent',
|
||||
border: isToday ? '1px solid rgba(14,165,233,0.5)' : '1px solid transparent',
|
||||
cursor: hasDue ? 'pointer' : 'default',
|
||||
transition: hasDue ? 'background 0.15s' : undefined,
|
||||
}}
|
||||
onMouseEnter={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.35)' : 'rgba(239,68,68,0.15)'; } : undefined}
|
||||
onMouseLeave={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.2)' : 'transparent'; } : undefined}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: '0.7rem', fontFamily: 'monospace', lineHeight: 1,
|
||||
color: isToday ? '#0EA5E9' : hasDue ? '#EF4444' : '#CBD5E1',
|
||||
fontWeight: (isToday || hasDue) ? '700' : '400',
|
||||
}}>
|
||||
{day}
|
||||
</span>
|
||||
{/* Red dot indicator for due dates */}
|
||||
{hasDue ? (
|
||||
<div style={{
|
||||
width: '4px', height: '4px', borderRadius: '50%',
|
||||
background: '#EF4444',
|
||||
boxShadow: '0 0 4px rgba(239,68,68,0.6)',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
) : (
|
||||
<div style={{ width: '4px', height: '4px' }} /> // spacer to keep rows even
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend — only shown when there are due dates this month */}
|
||||
{hasDueDatesThisMonth && (
|
||||
<div style={{ marginTop: '0.75rem', paddingTop: '0.625rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 4px rgba(239,68,68,0.5)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Ivanti finding due
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,97 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const STORAGE_KEY = 'steam_findings_columns_v1';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column definitions
|
||||
// Column definitions — source of truth for labels, sort behaviour, rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
const COLUMNS = [
|
||||
{ key: 'severity', label: 'Severity', accessor: (f) => f.severity, sortable: true },
|
||||
{ key: 'title', label: 'Title', accessor: (f) => f.title, sortable: true },
|
||||
{ key: 'hostName', label: 'Host', accessor: (f) => f.hostName, sortable: true },
|
||||
{ key: 'ipAddress', label: 'IP Address', accessor: (f) => f.ipAddress, sortable: true },
|
||||
{ key: 'dns', label: 'DNS', accessor: (f) => f.dns, sortable: true },
|
||||
{ key: 'slaStatus', label: 'SLA', accessor: (f) => f.slaStatus, sortable: true },
|
||||
{ key: 'discoveredOn',label: 'Discovered', accessor: (f) => f.discoveredOn,sortable: true },
|
||||
{ key: 'lastFoundOn', label: 'Last Found', accessor: (f) => f.lastFoundOn, sortable: true },
|
||||
{ key: 'source', label: 'Source', accessor: (f) => f.source, sortable: true },
|
||||
{ key: 'note', label: 'Notes', accessor: (f) => f.note, sortable: false },
|
||||
const COLUMN_DEFS = {
|
||||
findingId: { label: 'Finding ID', sortable: true, filterable: false },
|
||||
severity: { label: 'Severity', sortable: true, filterable: true },
|
||||
title: { label: 'Title', sortable: true, filterable: true },
|
||||
cves: { label: 'CVEs', sortable: true, filterable: true, multiValue: true },
|
||||
hostName: { label: 'Host', sortable: true, filterable: true },
|
||||
ipAddress: { label: 'IP Address', sortable: true, filterable: true },
|
||||
dns: { label: 'DNS', sortable: true, filterable: true },
|
||||
dueDate: { label: 'Due Date', sortable: true, filterable: true },
|
||||
slaStatus: { label: 'SLA', sortable: true, filterable: true },
|
||||
buOwnership: { label: 'BU', sortable: true, filterable: true },
|
||||
lastFoundOn: { label: 'Last Found', sortable: true, filterable: true },
|
||||
note: { label: 'Notes', sortable: false, filterable: false },
|
||||
};
|
||||
|
||||
const DEFAULT_COLUMN_ORDER = [
|
||||
{ key: 'findingId', visible: true },
|
||||
{ key: 'severity', visible: true },
|
||||
{ key: 'title', visible: true },
|
||||
{ key: 'cves', visible: true },
|
||||
{ key: 'hostName', visible: true },
|
||||
{ key: 'ipAddress', visible: true },
|
||||
{ key: 'dns', visible: true },
|
||||
{ key: 'dueDate', visible: true },
|
||||
{ key: 'slaStatus', visible: true },
|
||||
{ key: 'buOwnership', visible: true },
|
||||
{ key: 'lastFoundOn', visible: true },
|
||||
{ key: 'note', visible: true },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// Persist / load column config
|
||||
// ---------------------------------------------------------------------------
|
||||
function loadColumnOrder() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
||||
if (saved && Array.isArray(saved)) {
|
||||
const savedKeys = new Set(saved.map((c) => c.key));
|
||||
const merged = saved.filter((c) => COLUMN_DEFS[c.key]);
|
||||
DEFAULT_COLUMN_ORDER.forEach((d) => {
|
||||
if (!savedKeys.has(d.key)) merged.push({ ...d });
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return DEFAULT_COLUMN_ORDER.map((c) => ({ ...c }));
|
||||
}
|
||||
|
||||
function saveColumnOrder(order) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(order)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sort accessor by column key
|
||||
// ---------------------------------------------------------------------------
|
||||
function getVal(finding, key) {
|
||||
switch (key) {
|
||||
case 'findingId': return finding.id ?? '';
|
||||
case 'severity': return finding.severity ?? 0;
|
||||
case 'title': return finding.title ?? '';
|
||||
case 'hostName': return finding.hostName ?? '';
|
||||
case 'ipAddress': return finding.ipAddress ?? '';
|
||||
case 'dns': return finding.dns ?? '';
|
||||
case 'dueDate': return finding.dueDate ?? '';
|
||||
case 'slaStatus': return finding.slaStatus ?? '';
|
||||
case 'cves': return (finding.cves || []).length; // sort by CVE count
|
||||
case 'buOwnership': return finding.buOwnership ?? '';
|
||||
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
||||
case 'note': return finding.note ?? '';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter accessor — severity → vrrGroup label; cves handled as multi-value
|
||||
// ---------------------------------------------------------------------------
|
||||
function getFilterVal(finding, key) {
|
||||
if (key === 'severity') return finding.vrrGroup || '';
|
||||
if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic
|
||||
return String(getVal(finding, key) ?? '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function severityColor(vrrGroup) {
|
||||
switch ((vrrGroup || '').toUpperCase()) {
|
||||
@@ -35,16 +106,26 @@ function slaColor(slaStatus) {
|
||||
switch ((slaStatus || '').toUpperCase()) {
|
||||
case 'OVERDUE': return '#EF4444';
|
||||
case 'AT_RISK': return '#F59E0B';
|
||||
case 'OK': return '#10B981';
|
||||
case 'WITHIN_SLA': return '#10B981';
|
||||
default: return '#64748B';
|
||||
}
|
||||
}
|
||||
|
||||
function dueDateColor(dueDate) {
|
||||
if (!dueDate) return '#64748B';
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
const diffDays = Math.ceil((due - today) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays < 0) return '#EF4444';
|
||||
if (diffDays <= 30) return '#F59E0B';
|
||||
return '#94A3B8';
|
||||
}
|
||||
|
||||
function SortIcon({ colKey, sort }) {
|
||||
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '12px', height: '12px', opacity: 0.3, marginLeft: '4px', flexShrink: 0 }} />;
|
||||
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
||||
return sort.dir === 'asc'
|
||||
? <ChevronUp style={{ width: '12px', height: '12px', color: '#0EA5E9', marginLeft: '4px', flexShrink: 0 }} />
|
||||
: <ChevronDown style={{ width: '12px', height: '12px', color: '#0EA5E9', marginLeft: '4px', flexShrink: 0 }} />;
|
||||
? <ChevronUp style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />
|
||||
: <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -53,9 +134,15 @@ function SortIcon({ colKey, sort }) {
|
||||
function NoteCell({ findingId, initialNote }) {
|
||||
const [value, setValue] = useState(initialNote || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const lastSaved = useRef(initialNote || '');
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialNote || '');
|
||||
lastSaved.current = initialNote || '';
|
||||
}, [initialNote]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (value === (initialNote || '')) return; // nothing changed
|
||||
if (value === lastSaved.current) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch(`${API_BASE}/ivanti/findings/${encodeURIComponent(findingId)}/note`, {
|
||||
@@ -64,12 +151,13 @@ function NoteCell({ findingId, initialNote }) {
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ note: value })
|
||||
});
|
||||
lastSaved.current = value;
|
||||
} catch (e) {
|
||||
console.error('Failed to save note:', e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [findingId, value, initialNote]);
|
||||
}, [findingId, value]);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -81,29 +169,411 @@ function NoteCell({ findingId, initialNote }) {
|
||||
onBlur={save}
|
||||
placeholder="Add note…"
|
||||
style={{
|
||||
width: '100%',
|
||||
minWidth: '160px',
|
||||
width: '100%', minWidth: '160px',
|
||||
background: 'rgba(14,165,233,0.05)',
|
||||
border: '1px solid rgba(14,165,233,0.2)',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
color: '#CBD5E1',
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box'
|
||||
borderRadius: '4px', padding: '4px 8px',
|
||||
color: '#CBD5E1', fontSize: '0.75rem',
|
||||
fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.6)'; e.target.style.background = 'rgba(14,165,233,0.1)'; }}
|
||||
onBlurCapture={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.2)'; e.target.style.background = 'rgba(14,165,233,0.05)'; }}
|
||||
/>
|
||||
{saving && <Loader style={{ width: '10px', height: '10px', position: 'absolute', right: '6px', top: '6px', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />}
|
||||
{saving && <Loader style={{ width: '10px', height: '10px', position: 'absolute', right: '6px', top: '6px', color: '#0EA5E9' }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main ReportingPage component
|
||||
// ColumnManager — popover with drag-to-reorder and show/hide toggles
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ReportingPage() {
|
||||
function ColumnManager({ columnOrder, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dragIdx, setDragIdx] = useState(null);
|
||||
const [overIdx, setOverIdx] = useState(null);
|
||||
const panelRef = useRef(null);
|
||||
const btnRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (!panelRef.current?.contains(e.target) && !btnRef.current?.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const toggleVisible = (key) => {
|
||||
onChange(columnOrder.map((c) => c.key === key ? { ...c, visible: !c.visible } : c));
|
||||
};
|
||||
|
||||
const handleDragStart = (idx) => setDragIdx(idx);
|
||||
const handleDragOver = (e, idx) => { e.preventDefault(); setOverIdx(idx); };
|
||||
const handleDrop = (idx) => {
|
||||
if (dragIdx === null || dragIdx === idx) { setDragIdx(null); setOverIdx(null); return; }
|
||||
const updated = [...columnOrder];
|
||||
const [moved] = updated.splice(dragIdx, 1);
|
||||
updated.splice(idx, 0, moved);
|
||||
onChange(updated);
|
||||
setDragIdx(null);
|
||||
setOverIdx(null);
|
||||
};
|
||||
|
||||
const visibleCount = columnOrder.filter((c) => c.visible).length;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.07)',
|
||||
border: `1px solid rgba(14,165,233,${open ? '0.5' : '0.25'})`,
|
||||
borderRadius: '0.375rem',
|
||||
color: '#0EA5E9', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em'
|
||||
}}
|
||||
>
|
||||
<Settings2 style={{ width: '13px', height: '13px' }} />
|
||||
Columns
|
||||
<span style={{ fontSize: '0.65rem', opacity: 0.7 }}>({visibleCount}/{columnOrder.length})</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 8px)', right: 0,
|
||||
width: '220px', zIndex: 100,
|
||||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||
border: '1px solid rgba(14,165,233,0.25)',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
|
||||
padding: '0.5rem'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.65rem', color: '#475569', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', padding: '0.25rem 0.5rem 0.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)', marginBottom: '0.375rem' }}>
|
||||
Drag to reorder · click to toggle
|
||||
</div>
|
||||
{columnOrder.map((col, idx) => {
|
||||
const def = COLUMN_DEFS[col.key];
|
||||
const isDragging = dragIdx === idx;
|
||||
const isOver = overIdx === idx && dragIdx !== null && dragIdx !== idx;
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={() => handleDrop(idx)}
|
||||
onDragEnd={() => { setDragIdx(null); setOverIdx(null); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.4rem 0.5rem', borderRadius: '0.25rem', cursor: 'grab',
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
background: isOver ? 'rgba(14,165,233,0.12)' : 'transparent',
|
||||
borderTop: isOver ? '2px solid #0EA5E9' : '2px solid transparent',
|
||||
transition: 'background 0.1s'
|
||||
}}
|
||||
>
|
||||
<GripVertical style={{ width: '14px', height: '14px', color: '#334155', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: '0.78rem', color: col.visible ? '#CBD5E1' : '#475569', fontFamily: 'monospace' }}>
|
||||
{def?.label || col.key}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleVisible(col.key); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: col.visible ? '#0EA5E9' : '#334155', lineHeight: 1 }}
|
||||
>
|
||||
{col.visible ? <Eye style={{ width: '14px', height: '14px' }} /> : <EyeOff style={{ width: '14px', height: '14px' }} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FilterDropdown — portal-based so it escapes overflow:auto clipping
|
||||
// ---------------------------------------------------------------------------
|
||||
function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChange, onClose }) {
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||
const [search, setSearch] = useState('');
|
||||
const panelRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Compute fixed position from anchor button's viewport rect
|
||||
useEffect(() => {
|
||||
if (!anchorEl) return;
|
||||
const r = anchorEl.getBoundingClientRect();
|
||||
setPos({ top: r.bottom + 4, left: r.left });
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [anchorEl]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target) &&
|
||||
!(anchorEl && anchorEl.contains(e.target))) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [anchorEl, onClose]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
// Unique values from the full (unfiltered) findings list.
|
||||
// Multi-value columns (e.g. cves) expand their array so each item is a separate option.
|
||||
const allValues = useMemo(() => {
|
||||
const def = COLUMN_DEFS[colKey];
|
||||
const vals = new Set();
|
||||
findings.forEach((f) => {
|
||||
if (def?.multiValue) {
|
||||
(f[colKey] || []).forEach((v) => { if (String(v).trim()) vals.add(String(v).trim()); });
|
||||
} else {
|
||||
const v = getFilterVal(f, colKey).trim();
|
||||
if (v) vals.add(v);
|
||||
}
|
||||
});
|
||||
return [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
}, [findings, colKey]);
|
||||
|
||||
const displayed = search.trim()
|
||||
? allValues.filter((v) => v.toLowerCase().includes(search.toLowerCase()))
|
||||
: allValues;
|
||||
|
||||
const isChecked = (val) => !activeFilter || activeFilter.has(val);
|
||||
const activeCount = activeFilter ? activeFilter.size : allValues.length;
|
||||
|
||||
const toggle = (val) => {
|
||||
let next;
|
||||
if (!activeFilter) {
|
||||
next = new Set(allValues);
|
||||
next.delete(val);
|
||||
} else {
|
||||
next = new Set(activeFilter);
|
||||
if (next.has(val)) next.delete(val); else next.add(val);
|
||||
}
|
||||
// If all values selected again, remove the filter entirely
|
||||
onFilterChange(next.size >= allValues.length ? null : next);
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left,
|
||||
width: '220px', zIndex: 9999,
|
||||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||
border: '1px solid rgba(14,165,233,0.3)',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.8)',
|
||||
padding: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{/* Search */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search values…"
|
||||
style={{
|
||||
width: '100%', marginBottom: '0.375rem',
|
||||
background: 'rgba(14,165,233,0.05)',
|
||||
border: '1px solid rgba(14,165,233,0.2)',
|
||||
borderRadius: '0.25rem', padding: '0.3rem 0.5rem',
|
||||
color: '#CBD5E1', fontSize: '0.72rem',
|
||||
fontFamily: 'monospace', outline: 'none', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Select All / Clear */}
|
||||
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '0.375rem', paddingBottom: '0.375rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<button
|
||||
onClick={() => onFilterChange(null)}
|
||||
style={{ flex: 1, padding: '0.2rem', background: 'rgba(14,165,233,0.08)', border: '1px solid rgba(14,165,233,0.2)', borderRadius: '0.25rem', color: '#0EA5E9', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onFilterChange(new Set())}
|
||||
style={{ flex: 1, padding: '0.2rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '0.25rem', color: '#EF4444', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Value checkboxes */}
|
||||
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
||||
{displayed.length === 0 ? (
|
||||
<div style={{ fontSize: '0.68rem', color: '#475569', textAlign: 'center', padding: '0.5rem 0' }}>No values</div>
|
||||
) : displayed.map((val) => (
|
||||
<label
|
||||
key={val}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0.375rem', borderRadius: '0.25rem', cursor: 'pointer', color: isChecked(val) ? '#CBD5E1' : '#475569', fontSize: '0.72rem', fontFamily: 'monospace' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.08)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked(val)}
|
||||
onChange={() => toggle(val)}
|
||||
style={{ accentColor: '#0EA5E9', width: '12px', height: '12px', flexShrink: 0, cursor: 'pointer' }}
|
||||
/>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status footer */}
|
||||
<div style={{ marginTop: '0.375rem', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: '0.65rem', color: '#475569', textAlign: 'center', fontFamily: 'monospace' }}>
|
||||
{activeCount} / {allValues.length} selected
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render a single table cell by column key
|
||||
// ---------------------------------------------------------------------------
|
||||
function TableCell({ colKey, finding }) {
|
||||
switch (colKey) {
|
||||
case 'findingId':
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#475569', fontFamily: 'monospace', fontSize: '0.68rem' }}>
|
||||
{finding.id || '—'}
|
||||
</td>
|
||||
);
|
||||
case 'severity': {
|
||||
const sc = severityColor(finding.vrrGroup);
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', padding: '0.2rem 0.45rem', borderRadius: '0.25rem', background: sc.bg, border: `1px solid ${sc.border}40`, fontFamily: 'monospace', fontWeight: '700', color: sc.text, fontSize: '0.72rem' }}>
|
||||
{finding.severity?.toFixed(2)}
|
||||
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{finding.vrrGroup}</span>
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case 'title':
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '280px' }}>
|
||||
<span style={{ color: '#CBD5E1', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.title}>
|
||||
{finding.title}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case 'cves': {
|
||||
const cves = finding.cves || [];
|
||||
if (cves.length === 0) return <td style={{ padding: '0.45rem 0.75rem', color: '#475569' }}>—</td>;
|
||||
const shown = cves.slice(0, 2);
|
||||
const rest = cves.length - shown.length;
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', minWidth: '160px', maxWidth: '240px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
|
||||
{shown.map((cve) => (
|
||||
<span key={cve} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)', color: '#A78BFA', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600', whiteSpace: 'nowrap' }}>
|
||||
{cve}
|
||||
</span>
|
||||
))}
|
||||
{rest > 0 && (
|
||||
<span title={cves.slice(2).join('\n')} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(100,116,139,0.12)', border: '1px solid rgba(100,116,139,0.25)', color: '#64748B', fontFamily: 'monospace', fontSize: '0.65rem', cursor: 'help', whiteSpace: 'nowrap' }}>
|
||||
+{rest} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case 'hostName':
|
||||
case 'ipAddress':
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
{finding[colKey] || '—'}
|
||||
</td>
|
||||
);
|
||||
case 'dns':
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '200px' }}>
|
||||
<span style={{ color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.dns}>
|
||||
{finding.dns || '—'}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case 'dueDate': {
|
||||
const color = dueDateColor(finding.dueDate);
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600', color }}>
|
||||
{finding.dueDate || '—'}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case 'slaStatus':
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: slaColor(finding.slaStatus) }}>
|
||||
{finding.slaStatus || '—'}
|
||||
</td>
|
||||
);
|
||||
case 'buOwnership': {
|
||||
const bu = finding.buOwnership || '';
|
||||
const isSteam = bu.toUpperCase().includes('STEAM');
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
{bu ? (
|
||||
<span
|
||||
title={bu}
|
||||
style={{
|
||||
display: 'inline-block', padding: '0.15rem 0.4rem',
|
||||
borderRadius: '0.25rem',
|
||||
background: isSteam ? 'rgba(14,165,233,0.1)' : 'rgba(245,158,11,0.1)',
|
||||
border: `1px solid ${isSteam ? 'rgba(14,165,233,0.3)' : 'rgba(245,158,11,0.3)'}`,
|
||||
color: isSteam ? '#0EA5E9' : '#F59E0B',
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{bu.replace('NTS-AEO-', '')}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#475569' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case 'lastFoundOn':
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
{finding.lastFoundOn || '—'}
|
||||
</td>
|
||||
);
|
||||
case 'note':
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem' }}>
|
||||
<NoteCell findingId={finding.id} initialNote={finding.note} />
|
||||
</td>
|
||||
);
|
||||
default:
|
||||
return <td style={{ padding: '0.45rem 0.75rem', color: '#64748B' }}>—</td>;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main ReportingPage
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ReportingPage({ filterDate }) {
|
||||
const [findings, setFindings] = useState([]);
|
||||
const [total, setTotal] = useState(null);
|
||||
const [syncedAt, setSyncedAt] = useState(null);
|
||||
@@ -112,6 +582,17 @@ export default function ReportingPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
||||
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
|
||||
const [columnFilters, setColumnFilters] = useState(() =>
|
||||
filterDate ? { dueDate: new Set([filterDate]) } : {}
|
||||
);
|
||||
const [openFilter, setOpenFilter] = useState(null);
|
||||
const filterBtnRefs = useRef({});
|
||||
|
||||
const updateColumns = useCallback((newOrder) => {
|
||||
setColumnOrder(newOrder);
|
||||
saveColumnOrder(newOrder);
|
||||
}, []);
|
||||
|
||||
const applyState = (data) => {
|
||||
setTotal(data.total ?? 0);
|
||||
@@ -137,10 +618,7 @@ export default function ReportingPage() {
|
||||
const syncFindings = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok) applyState(data);
|
||||
} catch (e) {
|
||||
@@ -152,12 +630,42 @@ export default function ReportingPage() {
|
||||
|
||||
useEffect(() => { fetchFindings(); }, []); // eslint-disable-line
|
||||
|
||||
// Sort findings
|
||||
const sorted = [...findings].sort((a, b) => {
|
||||
const col = COLUMNS.find((c) => c.key === sort.field);
|
||||
if (!col) return 0;
|
||||
const av = col.accessor(a) ?? '';
|
||||
const bv = col.accessor(b) ?? '';
|
||||
// Set/clear a single column filter
|
||||
const setColFilter = useCallback((colKey, vals) => {
|
||||
setColumnFilters((prev) => {
|
||||
if (!vals) {
|
||||
const next = { ...prev };
|
||||
delete next[colKey];
|
||||
return next;
|
||||
}
|
||||
return { ...prev, [colKey]: vals };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Apply all active column filters to produce the visible row set
|
||||
const filtered = useMemo(() => {
|
||||
const active = Object.entries(columnFilters);
|
||||
if (active.length === 0) return findings;
|
||||
return findings.filter((f) =>
|
||||
active.every(([key, vals]) => {
|
||||
if (!vals || vals.size === 0) return false;
|
||||
const def = COLUMN_DEFS[key];
|
||||
if (def?.multiValue) {
|
||||
// Row matches if ANY of its values is in the selected set
|
||||
return (f[key] || []).some((v) => vals.has(String(v).trim()));
|
||||
}
|
||||
return vals.has(getFilterVal(f, key).trim());
|
||||
})
|
||||
);
|
||||
}, [findings, columnFilters]);
|
||||
|
||||
// Visible columns in current order
|
||||
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]);
|
||||
|
||||
// Sort filtered results
|
||||
const sorted = useMemo(() => [...filtered].sort((a, b) => {
|
||||
const av = getVal(a, sort.field);
|
||||
const bv = getVal(b, sort.field);
|
||||
let cmp = 0;
|
||||
if (typeof av === 'number' && typeof bv === 'number') {
|
||||
cmp = av - bv;
|
||||
@@ -165,7 +673,7 @@ export default function ReportingPage() {
|
||||
cmp = String(av).localeCompare(String(bv), undefined, { numeric: true });
|
||||
}
|
||||
return sort.dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}), [filtered, sort]);
|
||||
|
||||
const toggleSort = (key) => {
|
||||
setSort((prev) =>
|
||||
@@ -175,8 +683,10 @@ export default function ReportingPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const activeFilterCount = Object.keys(columnFilters).length;
|
||||
|
||||
const syncedDisplay = syncedAt
|
||||
? new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()
|
||||
? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
||||
: 'Never synced';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -186,7 +696,7 @@ export default function ReportingPage() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
|
||||
{/* ----------------------------------------------------------------
|
||||
Panel 1 — Metrics placeholder (full width)
|
||||
Panel 1 — Metrics placeholder
|
||||
---------------------------------------------------------------- */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
||||
@@ -202,13 +712,7 @@ export default function ReportingPage() {
|
||||
Metric Graphs
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '120px',
|
||||
border: '1px dashed rgba(245,158,11,0.2)',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'rgba(245,158,11,0.03)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '120px', border: '1px dashed rgba(245,158,11,0.2)', borderRadius: '0.375rem', background: 'rgba(245,158,11,0.03)' }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
Pie charts & metrics — coming soon
|
||||
</p>
|
||||
@@ -226,7 +730,7 @@ export default function ReportingPage() {
|
||||
padding: '1.5rem',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
|
||||
}}>
|
||||
{/* Table header row */}
|
||||
{/* Panel header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
||||
<div>
|
||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14,165,233,0.4)', margin: '0 0 4px 0' }}>
|
||||
@@ -235,10 +739,39 @@ export default function ReportingPage() {
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
|
||||
{syncedDisplay}
|
||||
{syncStatus === 'success' && total !== null && (
|
||||
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>{total} total findings</span>
|
||||
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>
|
||||
{activeFilterCount > 0 ? `${filtered.length} of ${total}` : total} findings
|
||||
{activeFilterCount > 0 && (
|
||||
<span style={{ marginLeft: '0.5rem', color: '#F59E0B' }}>
|
||||
({activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
onClick={() => setColumnFilters({})}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: 'rgba(245,158,11,0.08)',
|
||||
border: '1px solid rgba(245,158,11,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#F59E0B', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em'
|
||||
}}
|
||||
>
|
||||
<Filter style={{ width: '11px', height: '11px' }} />
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
||||
<button
|
||||
onClick={syncFindings}
|
||||
disabled={syncing || loading}
|
||||
@@ -258,6 +791,7 @@ export default function ReportingPage() {
|
||||
{syncing ? 'Syncing…' : 'Sync'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{syncStatus === 'error' && syncError && (
|
||||
@@ -267,7 +801,7 @@ export default function ReportingPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
||||
<Loader style={{ width: '28px', height: '28px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto 0.75rem' }} />
|
||||
@@ -278,41 +812,61 @@ export default function ReportingPage() {
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Click Sync to load findings data</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Table */
|
||||
<div style={{ overflowX: 'auto', marginTop: '0.75rem' }}>
|
||||
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: 'calc(100vh - 420px)', minHeight: '200px', marginTop: '0.75rem' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
|
||||
{COLUMNS.map((col) => (
|
||||
{visibleCols.map((col) => {
|
||||
const def = COLUMN_DEFS[col.key];
|
||||
const active = sort.field === col.key;
|
||||
const isFiltered = !!columnFilters[col.key];
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
onClick={col.sortable ? () => toggleSort(col.key) : undefined}
|
||||
onClick={def?.sortable ? () => toggleSort(col.key) : undefined}
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
textAlign: 'left',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: '600',
|
||||
color: sort.field === col.key ? '#0EA5E9' : '#64748B',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
padding: '0.5rem 0.75rem', textAlign: 'left',
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
color: active ? '#0EA5E9' : '#64748B',
|
||||
textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: col.sortable ? 'pointer' : 'default',
|
||||
cursor: def?.sortable ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
background: 'rgba(15,26,46,0.6)'
|
||||
background: 'rgb(10, 20, 36)',
|
||||
position: 'sticky', top: 0, zIndex: 10,
|
||||
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
{col.label}
|
||||
{col.sortable && <SortIcon colKey={col.key} sort={sort} />}
|
||||
{def?.label || col.key}
|
||||
{def?.sortable && <SortIcon colKey={col.key} sort={sort} />}
|
||||
{def?.filterable && (
|
||||
<button
|
||||
ref={(el) => { filterBtnRefs.current[col.key] = el; }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilter(openFilter === col.key ? null : col.key);
|
||||
}}
|
||||
title={`Filter ${def.label}`}
|
||||
style={{
|
||||
background: 'none', border: 'none',
|
||||
cursor: 'pointer', padding: '1px 1px 1px 3px',
|
||||
color: isFiltered ? '#F59E0B' : '#334155',
|
||||
lineHeight: 1, flexShrink: 0,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
<Filter style={{ width: '10px', height: '10px' }} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((finding, idx) => {
|
||||
const sc = severityColor(finding.vrrGroup);
|
||||
const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)';
|
||||
return (
|
||||
<tr
|
||||
@@ -321,71 +875,16 @@ export default function ReportingPage() {
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
|
||||
>
|
||||
{/* Severity */}
|
||||
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.375rem', padding: '0.2rem 0.5rem', borderRadius: '0.25rem', background: sc.bg, border: `1px solid ${sc.border}40`, fontFamily: 'monospace', fontWeight: '700', color: sc.text, fontSize: '0.72rem' }}>
|
||||
{finding.severity?.toFixed(2)}
|
||||
<span style={{ fontSize: '0.6rem', opacity: 0.8 }}>{finding.vrrGroup}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Title */}
|
||||
<td style={{ padding: '0.5rem 0.75rem', maxWidth: '280px' }}>
|
||||
<span style={{ color: '#CBD5E1', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.title}>
|
||||
{finding.title}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Host */}
|
||||
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
{finding.hostName || '—'}
|
||||
</td>
|
||||
|
||||
{/* IP */}
|
||||
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
{finding.ipAddress || '—'}
|
||||
</td>
|
||||
|
||||
{/* DNS */}
|
||||
<td style={{ padding: '0.5rem 0.75rem', maxWidth: '200px' }}>
|
||||
<span style={{ color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.dns}>
|
||||
{finding.dns || '—'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* SLA */}
|
||||
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: slaColor(finding.slaStatus) }}>
|
||||
{finding.slaStatus || '—'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Discovered */}
|
||||
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
{finding.discoveredOn || '—'}
|
||||
</td>
|
||||
|
||||
{/* Last Found */}
|
||||
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
{finding.lastFoundOn || '—'}
|
||||
</td>
|
||||
|
||||
{/* Source */}
|
||||
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.68rem' }}>
|
||||
{finding.source || '—'}
|
||||
</td>
|
||||
|
||||
{/* Notes */}
|
||||
<td style={{ padding: '0.5rem 0.75rem' }}>
|
||||
<NoteCell findingId={finding.id} initialNote={finding.note} />
|
||||
</td>
|
||||
{visibleCols.map((col) => (
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={COLUMNS.length} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
No findings found
|
||||
<td colSpan={visibleCols.length} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -394,6 +893,18 @@ export default function ReportingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter dropdown — rendered via portal at document.body */}
|
||||
{openFilter && COLUMN_DEFS[openFilter]?.filterable && (
|
||||
<FilterDropdown
|
||||
anchorEl={filterBtnRefs.current[openFilter]}
|
||||
colKey={openFilter}
|
||||
findings={findings}
|
||||
activeFilter={columnFilters[openFilter] || null}
|
||||
onFilterChange={(vals) => setColFilter(openFilter, vals)}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user