Reporting page: add Due Date, column manager (hide/reorder), remove Discovered/Source
Backend: - Extract dueDate from statusEmbedded.dueDate (strip time portion) - Remove discoveredOn and source from extractFinding (not needed) Frontend: - Add Due Date column (color-coded: red=past due, amber=within 30d, gray=future) - Remove Discovered and Source columns - ColumnManager component: gear button opens popover with drag-to-reorder and eye toggle per column; column state persisted to localStorage - Column order/visibility survives page refresh and syncs - SortIcon, TableCell, NoteCell all driven by current visible column list Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,10 @@ function initTables(db) {
|
|||||||
// Extract only the fields we need from a raw finding object
|
// Extract only the fields we need from a raw finding object
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function extractFinding(f) {
|
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] : '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(f.id),
|
id: String(f.id),
|
||||||
title: f.title || '',
|
title: f.title || '',
|
||||||
@@ -130,11 +134,8 @@ function extractFinding(f) {
|
|||||||
dns: f.dns || f.host?.fqdn || '',
|
dns: f.dns || f.host?.fqdn || '',
|
||||||
status: f.status || '',
|
status: f.status || '',
|
||||||
slaStatus: f.slaStatus || '',
|
slaStatus: f.slaStatus || '',
|
||||||
discoveredOn: f.discoveredOn || '',
|
dueDate,
|
||||||
lastFoundOn: f.lastFoundOn || '',
|
lastFoundOn: f.lastFoundOn || ''
|
||||||
source: f.scannerPrettyName || f.scannerName || f.source || '',
|
|
||||||
pluginFamily: f.pluginFamily || '',
|
|
||||||
findingType: f.findingType || ''
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,79 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
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 = [
|
const COLUMN_DEFS = {
|
||||||
{ key: 'severity', label: 'Severity', accessor: (f) => f.severity, sortable: true },
|
severity: { label: 'Severity', sortable: true },
|
||||||
{ key: 'title', label: 'Title', accessor: (f) => f.title, sortable: true },
|
title: { label: 'Title', sortable: true },
|
||||||
{ key: 'hostName', label: 'Host', accessor: (f) => f.hostName, sortable: true },
|
hostName: { label: 'Host', sortable: true },
|
||||||
{ key: 'ipAddress', label: 'IP Address', accessor: (f) => f.ipAddress, sortable: true },
|
ipAddress: { label: 'IP Address', sortable: true },
|
||||||
{ key: 'dns', label: 'DNS', accessor: (f) => f.dns, sortable: true },
|
dns: { label: 'DNS', sortable: true },
|
||||||
{ key: 'slaStatus', label: 'SLA', accessor: (f) => f.slaStatus, sortable: true },
|
dueDate: { label: 'Due Date', sortable: true },
|
||||||
{ key: 'discoveredOn',label: 'Discovered', accessor: (f) => f.discoveredOn,sortable: true },
|
slaStatus: { label: 'SLA', sortable: true },
|
||||||
{ key: 'lastFoundOn', label: 'Last Found', accessor: (f) => f.lastFoundOn, sortable: true },
|
lastFoundOn: { label: 'Last Found', sortable: true },
|
||||||
{ key: 'source', label: 'Source', accessor: (f) => f.source, sortable: true },
|
note: { label: 'Notes', sortable: false },
|
||||||
{ key: 'note', label: 'Notes', accessor: (f) => f.note, sortable: false },
|
};
|
||||||
|
|
||||||
|
const DEFAULT_COLUMN_ORDER = [
|
||||||
|
{ key: 'severity', visible: true },
|
||||||
|
{ key: 'title', visible: true },
|
||||||
|
{ key: 'hostName', visible: true },
|
||||||
|
{ key: 'ipAddress', visible: true },
|
||||||
|
{ key: 'dns', visible: true },
|
||||||
|
{ key: 'dueDate', visible: true },
|
||||||
|
{ key: 'slaStatus', 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)) {
|
||||||
|
// Keep saved order/visibility; add any new default columns at the end
|
||||||
|
const savedKeys = new Set(saved.map((c) => c.key));
|
||||||
|
const merged = saved.filter((c) => COLUMN_DEFS[c.key]); // drop removed cols
|
||||||
|
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 '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 'lastFoundOn': return finding.lastFoundOn ?? '';
|
||||||
|
case 'note': return finding.note ?? '';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Style helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function severityColor(vrrGroup) {
|
function severityColor(vrrGroup) {
|
||||||
switch ((vrrGroup || '').toUpperCase()) {
|
switch ((vrrGroup || '').toUpperCase()) {
|
||||||
@@ -35,16 +88,26 @@ function slaColor(slaStatus) {
|
|||||||
switch ((slaStatus || '').toUpperCase()) {
|
switch ((slaStatus || '').toUpperCase()) {
|
||||||
case 'OVERDUE': return '#EF4444';
|
case 'OVERDUE': return '#EF4444';
|
||||||
case 'AT_RISK': return '#F59E0B';
|
case 'AT_RISK': return '#F59E0B';
|
||||||
case 'OK': return '#10B981';
|
case 'WITHIN_SLA': return '#10B981';
|
||||||
default: return '#64748B';
|
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'; // overdue
|
||||||
|
if (diffDays <= 30) return '#F59E0B'; // due soon
|
||||||
|
return '#94A3B8';
|
||||||
|
}
|
||||||
|
|
||||||
function SortIcon({ colKey, sort }) {
|
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'
|
return sort.dir === 'asc'
|
||||||
? <ChevronUp 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: '12px', height: '12px', color: '#0EA5E9', marginLeft: '4px', flexShrink: 0 }} />;
|
: <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -53,9 +116,15 @@ function SortIcon({ colKey, sort }) {
|
|||||||
function NoteCell({ findingId, initialNote }) {
|
function NoteCell({ findingId, initialNote }) {
|
||||||
const [value, setValue] = useState(initialNote || '');
|
const [value, setValue] = useState(initialNote || '');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const lastSaved = useRef(initialNote || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(initialNote || '');
|
||||||
|
lastSaved.current = initialNote || '';
|
||||||
|
}, [initialNote]);
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
if (value === (initialNote || '')) return; // nothing changed
|
if (value === lastSaved.current) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_BASE}/ivanti/findings/${encodeURIComponent(findingId)}/note`, {
|
await fetch(`${API_BASE}/ivanti/findings/${encodeURIComponent(findingId)}/note`, {
|
||||||
@@ -64,12 +133,13 @@ function NoteCell({ findingId, initialNote }) {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ note: value })
|
body: JSON.stringify({ note: value })
|
||||||
});
|
});
|
||||||
|
lastSaved.current = value;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save note:', e);
|
console.error('Failed to save note:', e);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [findingId, value, initialNote]);
|
}, [findingId, value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
@@ -81,27 +151,216 @@ function NoteCell({ findingId, initialNote }) {
|
|||||||
onBlur={save}
|
onBlur={save}
|
||||||
placeholder="Add note…"
|
placeholder="Add note…"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%', minWidth: '160px',
|
||||||
minWidth: '160px',
|
|
||||||
background: 'rgba(14,165,233,0.05)',
|
background: 'rgba(14,165,233,0.05)',
|
||||||
border: '1px solid rgba(14,165,233,0.2)',
|
border: '1px solid rgba(14,165,233,0.2)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px', padding: '4px 8px',
|
||||||
padding: '4px 8px',
|
color: '#CBD5E1', fontSize: '0.75rem',
|
||||||
color: '#CBD5E1',
|
fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box'
|
||||||
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)'; }}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main ReportingPage component
|
// ColumnManager — popover with drag-to-reorder and show/hide toggles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
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) => {
|
||||||
|
const updated = columnOrder.map((c) => c.key === key ? { ...c, visible: !c.visible } : c);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render a single table cell by column key
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function TableCell({ colKey, finding }) {
|
||||||
|
switch (colKey) {
|
||||||
|
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 '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 '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() {
|
export default function ReportingPage() {
|
||||||
const [findings, setFindings] = useState([]);
|
const [findings, setFindings] = useState([]);
|
||||||
@@ -112,6 +371,13 @@ export default function ReportingPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
||||||
|
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
|
||||||
|
|
||||||
|
// Persist column changes
|
||||||
|
const updateColumns = useCallback((newOrder) => {
|
||||||
|
setColumnOrder(newOrder);
|
||||||
|
saveColumnOrder(newOrder);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const applyState = (data) => {
|
const applyState = (data) => {
|
||||||
setTotal(data.total ?? 0);
|
setTotal(data.total ?? 0);
|
||||||
@@ -152,12 +418,13 @@ export default function ReportingPage() {
|
|||||||
|
|
||||||
useEffect(() => { fetchFindings(); }, []); // eslint-disable-line
|
useEffect(() => { fetchFindings(); }, []); // eslint-disable-line
|
||||||
|
|
||||||
// Sort findings
|
// Visible columns in current order
|
||||||
|
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]);
|
||||||
|
|
||||||
|
// Sorted findings
|
||||||
const sorted = [...findings].sort((a, b) => {
|
const sorted = [...findings].sort((a, b) => {
|
||||||
const col = COLUMNS.find((c) => c.key === sort.field);
|
const av = getVal(a, sort.field);
|
||||||
if (!col) return 0;
|
const bv = getVal(b, sort.field);
|
||||||
const av = col.accessor(a) ?? '';
|
|
||||||
const bv = col.accessor(b) ?? '';
|
|
||||||
let cmp = 0;
|
let cmp = 0;
|
||||||
if (typeof av === 'number' && typeof bv === 'number') {
|
if (typeof av === 'number' && typeof bv === 'number') {
|
||||||
cmp = av - bv;
|
cmp = av - bv;
|
||||||
@@ -176,7 +443,7 @@ export default function ReportingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const syncedDisplay = syncedAt
|
const syncedDisplay = syncedAt
|
||||||
? new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()
|
? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
||||||
: 'Never synced';
|
: 'Never synced';
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -186,7 +453,7 @@ export default function ReportingPage() {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||||
|
|
||||||
{/* ----------------------------------------------------------------
|
{/* ----------------------------------------------------------------
|
||||||
Panel 1 — Metrics placeholder (full width)
|
Panel 1 — Metrics placeholder
|
||||||
---------------------------------------------------------------- */}
|
---------------------------------------------------------------- */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
||||||
@@ -202,13 +469,7 @@ export default function ReportingPage() {
|
|||||||
Metric Graphs
|
Metric Graphs
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<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)' }}>
|
||||||
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' }}>
|
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||||
Pie charts & metrics — coming soon
|
Pie charts & metrics — coming soon
|
||||||
</p>
|
</p>
|
||||||
@@ -226,7 +487,7 @@ export default function ReportingPage() {
|
|||||||
padding: '1.5rem',
|
padding: '1.5rem',
|
||||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
|
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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
||||||
<div>
|
<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' }}>
|
<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 +496,14 @@ export default function ReportingPage() {
|
|||||||
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
|
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
|
||||||
{syncedDisplay}
|
{syncedDisplay}
|
||||||
{syncStatus === 'success' && total !== null && (
|
{syncStatus === 'success' && total !== null && (
|
||||||
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>{total} total findings</span>
|
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>{total} findings</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
||||||
<button
|
<button
|
||||||
onClick={syncFindings}
|
onClick={syncFindings}
|
||||||
disabled={syncing || loading}
|
disabled={syncing || loading}
|
||||||
@@ -258,6 +523,7 @@ export default function ReportingPage() {
|
|||||||
{syncing ? 'Syncing…' : 'Sync'}
|
{syncing ? 'Syncing…' : 'Sync'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Error banner */}
|
{/* Error banner */}
|
||||||
{syncStatus === 'error' && syncError && (
|
{syncStatus === 'error' && syncError && (
|
||||||
@@ -267,7 +533,7 @@ export default function ReportingPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Content */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
<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' }} />
|
<Loader style={{ width: '28px', height: '28px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto 0.75rem' }} />
|
||||||
@@ -278,41 +544,40 @@ export default function ReportingPage() {
|
|||||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Click Sync to load findings data</p>
|
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Click Sync to load findings data</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Table */
|
|
||||||
<div style={{ overflowX: 'auto', marginTop: '0.75rem' }}>
|
<div style={{ overflowX: 'auto', marginTop: '0.75rem' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
|
<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;
|
||||||
|
return (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
onClick={col.sortable ? () => toggleSort(col.key) : undefined}
|
onClick={def?.sortable ? () => toggleSort(col.key) : undefined}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.5rem 0.75rem',
|
padding: '0.5rem 0.75rem',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||||
fontSize: '0.68rem',
|
color: active ? '#0EA5E9' : '#64748B',
|
||||||
fontWeight: '600',
|
textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||||
color: sort.field === col.key ? '#0EA5E9' : '#64748B',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.08em',
|
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
cursor: col.sortable ? 'pointer' : 'default',
|
cursor: def?.sortable ? 'pointer' : 'default',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
background: 'rgba(15,26,46,0.6)'
|
background: 'rgba(15,26,46,0.6)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||||
{col.label}
|
{def?.label || col.key}
|
||||||
{col.sortable && <SortIcon colKey={col.key} sort={sort} />}
|
{def?.sortable && <SortIcon colKey={col.key} sort={sort} />}
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sorted.map((finding, idx) => {
|
{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)';
|
const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)';
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -321,70 +586,15 @@ export default function ReportingPage() {
|
|||||||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
|
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
|
onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
|
||||||
>
|
>
|
||||||
{/* Severity */}
|
{visibleCols.map((col) => (
|
||||||
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap' }}>
|
<TableCell key={col.key} colKey={col.key} finding={finding} />
|
||||||
<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>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{sorted.length === 0 && (
|
{sorted.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={COLUMNS.length} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
<td colSpan={visibleCols.length} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||||
No findings found
|
No findings found
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user