Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
2026-03-11 11:47:03 -06:00
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Column definitions
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
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 },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function severityColor(vrrGroup) {
|
|
|
|
|
switch ((vrrGroup || '').toUpperCase()) {
|
|
|
|
|
case 'CRITICAL': return { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#EF4444' };
|
|
|
|
|
case 'HIGH': return { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#F59E0B' };
|
|
|
|
|
case 'MEDIUM': return { bg: 'rgba(234,179,8,0.15)', border: '#EAB308', text: '#EAB308' };
|
|
|
|
|
default: return { bg: 'rgba(100,116,139,0.15)', border: '#64748B', text: '#94A3B8' };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function slaColor(slaStatus) {
|
|
|
|
|
switch ((slaStatus || '').toUpperCase()) {
|
|
|
|
|
case 'OVERDUE': return '#EF4444';
|
|
|
|
|
case 'AT_RISK': return '#F59E0B';
|
|
|
|
|
case 'OK': return '#10B981';
|
|
|
|
|
default: return '#64748B';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SortIcon({ colKey, sort }) {
|
|
|
|
|
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '12px', height: '12px', opacity: 0.3, marginLeft: '4px', 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 }} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// NoteCell — inline editable, saves on blur
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function NoteCell({ findingId, initialNote }) {
|
|
|
|
|
const [value, setValue] = useState(initialNote || '');
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
|
|
|
|
const save = useCallback(async () => {
|
|
|
|
|
if (value === (initialNote || '')) return; // nothing changed
|
|
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
await fetch(`${API_BASE}/ivanti/findings/${encodeURIComponent(findingId)}/note`, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
body: JSON.stringify({ note: value })
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to save note:', e);
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
}, [findingId, value, initialNote]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ position: 'relative' }}>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={value}
|
|
|
|
|
maxLength={255}
|
|
|
|
|
onChange={(e) => setValue(e.target.value)}
|
|
|
|
|
onBlur={save}
|
|
|
|
|
placeholder="Add note…"
|
|
|
|
|
style={{
|
|
|
|
|
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'
|
|
|
|
|
}}
|
|
|
|
|
onFocus={(e) => { e.target.style.borderColor = 'rgba(14, 165, 233, 0.6)'; e.target.style.background = 'rgba(14, 165, 233, 0.1)'; }}
|
|
|
|
|
/>
|
|
|
|
|
{saving && <Loader style={{ width: '10px', height: '10px', position: 'absolute', right: '6px', top: '6px', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Main ReportingPage component
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-11 11:47:03 -06:00
|
|
|
export default function ReportingPage() {
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
const [findings, setFindings] = useState([]);
|
|
|
|
|
const [total, setTotal] = useState(null);
|
|
|
|
|
const [syncedAt, setSyncedAt] = useState(null);
|
|
|
|
|
const [syncStatus, setSyncStatus] = useState(null);
|
|
|
|
|
const [syncError, setSyncError] = useState(null);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [syncing, setSyncing] = useState(false);
|
|
|
|
|
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
|
|
|
|
|
|
|
|
|
const applyState = (data) => {
|
|
|
|
|
setTotal(data.total ?? 0);
|
|
|
|
|
setFindings(data.findings || []);
|
|
|
|
|
setSyncedAt(data.synced_at || null);
|
|
|
|
|
setSyncStatus(data.sync_status || null);
|
|
|
|
|
setSyncError(data.error_message || null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchFindings = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (res.ok) applyState(data);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Error loading findings:', e);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const syncFindings = async () => {
|
|
|
|
|
setSyncing(true);
|
|
|
|
|
try {
|
|
|
|
|
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) {
|
|
|
|
|
console.error('Error syncing findings:', e);
|
|
|
|
|
} finally {
|
|
|
|
|
setSyncing(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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) ?? '';
|
|
|
|
|
let cmp = 0;
|
|
|
|
|
if (typeof av === 'number' && typeof bv === 'number') {
|
|
|
|
|
cmp = av - bv;
|
|
|
|
|
} else {
|
|
|
|
|
cmp = String(av).localeCompare(String(bv), undefined, { numeric: true });
|
|
|
|
|
}
|
|
|
|
|
return sort.dir === 'asc' ? cmp : -cmp;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const toggleSort = (key) => {
|
|
|
|
|
setSort((prev) =>
|
|
|
|
|
prev.field === key
|
|
|
|
|
? { field: key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
|
|
|
|
|
: { field: key, dir: 'asc' }
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const syncedDisplay = syncedAt
|
|
|
|
|
? new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()
|
|
|
|
|
: 'Never synced';
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// Render
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
|
|
|
|
|
|
|
|
|
{/* ----------------------------------------------------------------
|
|
|
|
|
Panel 1 — Metrics placeholder (full width)
|
|
|
|
|
---------------------------------------------------------------- */}
|
|
|
|
|
<div style={{
|
|
|
|
|
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
|
|
|
|
border: '1px solid rgba(245,158,11,0.2)',
|
|
|
|
|
borderLeft: '3px solid #F59E0B',
|
|
|
|
|
borderRadius: '0.5rem',
|
|
|
|
|
padding: '1.5rem',
|
|
|
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '1rem' }}>
|
|
|
|
|
<PieChart style={{ width: '20px', height: '20px', color: '#F59E0B' }} />
|
|
|
|
|
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(245,158,11,0.4)', margin: 0 }}>
|
|
|
|
|
Metric Graphs
|
|
|
|
|
</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<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>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ----------------------------------------------------------------
|
|
|
|
|
Panel 2 — Findings table
|
|
|
|
|
---------------------------------------------------------------- */}
|
|
|
|
|
<div style={{
|
|
|
|
|
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
|
|
|
|
border: '1px solid rgba(14,165,233,0.2)',
|
|
|
|
|
borderLeft: '3px solid #0EA5E9',
|
|
|
|
|
borderRadius: '0.5rem',
|
|
|
|
|
padding: '1.5rem',
|
|
|
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
|
|
|
|
|
}}>
|
|
|
|
|
{/* Table header row */}
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
|
|
|
|
<div>
|
|
|
|
|
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14,165,233,0.4)', margin: '0 0 4px 0' }}>
|
|
|
|
|
Host Findings
|
|
|
|
|
</h2>
|
|
|
|
|
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
|
|
|
|
|
{syncedDisplay}
|
|
|
|
|
{syncStatus === 'success' && total !== null && (
|
|
|
|
|
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>{total} total findings</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={syncFindings}
|
|
|
|
|
disabled={syncing || loading}
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
|
|
|
|
padding: '0.375rem 0.75rem',
|
|
|
|
|
background: 'rgba(14,165,233,0.1)',
|
|
|
|
|
border: '1px solid rgba(14,165,233,0.35)',
|
|
|
|
|
borderRadius: '0.375rem',
|
|
|
|
|
color: '#0EA5E9', cursor: 'pointer',
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
|
|
|
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
|
|
|
|
opacity: (syncing || loading) ? 0.6 : 1
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw style={{ width: '13px', height: '13px', animation: syncing ? 'spin 1s linear infinite' : 'none' }} />
|
|
|
|
|
{syncing ? 'Syncing…' : 'Sync'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Error banner */}
|
|
|
|
|
{syncStatus === 'error' && syncError && (
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', padding: '0.625rem 0.875rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '0.375rem', marginBottom: '1rem' }}>
|
|
|
|
|
<AlertCircle style={{ width: '15px', height: '15px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
|
|
|
|
|
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>{syncError}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Loading state */}
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
|
|
|
|
<Loader style={{ width: '28px', height: '28px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto 0.75rem' }} />
|
|
|
|
|
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Loading findings…</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : syncStatus === 'never' ? (
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
|
|
|
|
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Click Sync to load findings data</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
/* Table */
|
|
|
|
|
<div style={{ overflowX: 'auto', 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) => (
|
|
|
|
|
<th
|
|
|
|
|
key={col.key}
|
|
|
|
|
onClick={col.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',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
cursor: col.sortable ? 'pointer' : 'default',
|
|
|
|
|
userSelect: 'none',
|
|
|
|
|
background: 'rgba(15,26,46,0.6)'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
|
|
|
|
{col.label}
|
|
|
|
|
{col.sortable && <SortIcon colKey={col.key} sort={sort} />}
|
|
|
|
|
</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
|
|
|
|
|
key={finding.id}
|
|
|
|
|
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', background: rowBg }}
|
|
|
|
|
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>
|
|
|
|
|
</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>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-11 11:47:03 -06:00
|
|
|
</div>
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
);
|
2026-03-11 11:47:03 -06:00
|
|
|
}
|