Compare commits
1 Commits
931c42faeb
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| d3806e8ce3 |
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// Migration: Add ivanti_findings_cache and ivanti_finding_notes tables
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting Ivanti findings tables migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Cache table — single row holding the latest sync result
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
findings_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating findings cache table:', err);
|
||||
else console.log('✓ ivanti_findings_cache table created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||
VALUES (1, 0, '[]', 'never')
|
||||
`, (err) => {
|
||||
if (err) console.error('Error seeding findings cache row:', err);
|
||||
else console.log('✓ ivanti_findings_cache row seeded');
|
||||
});
|
||||
|
||||
// Notes table — one row per finding, persists across cache refreshes
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating finding notes table:', err);
|
||||
else console.log('✓ ivanti_finding_notes table created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||
ON ivanti_finding_notes(finding_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating notes index:', err);
|
||||
else console.log('✓ finding_id index created');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
315
backend/routes/ivantiFindings.js
Normal file
315
backend/routes/ivantiFindings.js
Normal file
@@ -0,0 +1,315 @@
|
||||
// Ivanti / RiskSense Host Findings Routes
|
||||
// Caches hostFinding/search results in SQLite with daily auto-sync.
|
||||
// Notes are stored separately so they survive cache refreshes.
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
|
||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const FINDINGS_FILTERS = [
|
||||
{
|
||||
field: 'assetCustomAttributes.1550_host_1.value',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'severity',
|
||||
exclusive: false,
|
||||
operator: 'RANGE',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: '8.5,9.9',
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'generic_state',
|
||||
exclusive: false,
|
||||
operator: 'EXACT',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'Open',
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helper — mirrors the one in ivantiWorkflows.js
|
||||
// ---------------------------------------------------------------------------
|
||||
function ivantiPost(urlPath, body, apiKey, skipTls) {
|
||||
const bodyStr = JSON.stringify(body);
|
||||
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: fullUrl.hostname,
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'x-http-client-type': 'browser',
|
||||
'content-length': Buffer.byteLength(bodyStr)
|
||||
},
|
||||
rejectUnauthorized: !skipTls,
|
||||
timeout: 20000
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
||||
req.on('error', reject);
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table init
|
||||
// ---------------------------------------------------------------------------
|
||||
function initTables(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
findings_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||
VALUES (1, 0, '[]', 'never')
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||
ON ivanti_finding_notes(finding_id)
|
||||
`, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract only the fields we need from a raw finding object
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractFinding(f) {
|
||||
return {
|
||||
id: String(f.id),
|
||||
title: f.title || '',
|
||||
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||
vrrGroup: f.vrrGroup || f.severityGroup || '',
|
||||
hostName: f.host?.hostName || '',
|
||||
ipAddress: f.host?.ipAddress || '',
|
||||
dns: f.dns || f.host?.fqdn || '',
|
||||
status: f.status || '',
|
||||
slaStatus: f.slaStatus || '',
|
||||
discoveredOn: f.discoveredOn || '',
|
||||
lastFoundOn: f.lastFoundOn || '',
|
||||
source: f.scannerPrettyName || f.scannerName || f.source || '',
|
||||
pluginFamily: f.pluginFamily || '',
|
||||
findingType: f.findingType || ''
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
||||
// ---------------------------------------------------------------------------
|
||||
async function syncFindings(db) {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
if (!apiKey) {
|
||||
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
||||
console.warn('[Ivanti Findings]', errMsg);
|
||||
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg]);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Ivanti Findings] Starting sync...');
|
||||
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
let allFindings = [];
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
|
||||
try {
|
||||
do {
|
||||
const body = {
|
||||
filters: FINDINGS_FILTERS,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page,
|
||||
size: 100
|
||||
};
|
||||
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
|
||||
if (result.status === 401) throw new Error('Invalid or missing API key (401)');
|
||||
if (result.status === 419) throw new Error('Insufficient privileges (419) — API key lacks hostFinding access');
|
||||
if (result.status === 429) throw new Error('Rate limited (429) — try again later');
|
||||
if (result.status !== 200) throw new Error(`Ivanti API returned status ${result.status}`);
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
allFindings = allFindings.concat(findings.map(extractFinding));
|
||||
|
||||
console.log(`[Ivanti Findings] Page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`);
|
||||
page++;
|
||||
} while (page < totalPages);
|
||||
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
|
||||
[allFindings.length, JSON.stringify(allFindings)]
|
||||
);
|
||||
|
||||
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Unknown error';
|
||||
console.error('[Ivanti Findings] Sync failed:', msg);
|
||||
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------------------------
|
||||
function scheduleSync(db) {
|
||||
db.get('SELECT synced_at FROM ivanti_findings_cache WHERE id = 1', (err, row) => {
|
||||
if (err || !row || !row.synced_at) {
|
||||
syncFindings(db);
|
||||
} else {
|
||||
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
||||
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
||||
if (hoursSince >= 24) {
|
||||
syncFindings(db);
|
||||
} else {
|
||||
console.log(`[Ivanti Findings] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${(24 - hoursSince).toFixed(1)}h`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => syncFindings(db), SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, (err) => { if (err) reject(err); else resolve(); });
|
||||
});
|
||||
}
|
||||
|
||||
function readState(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT total, findings_json, synced_at, sync_status, error_message FROM ivanti_findings_cache WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) return reject(err);
|
||||
if (!row) return resolve({ total: 0, findings: [], synced_at: null, sync_status: 'never', error_message: null });
|
||||
let findings = [];
|
||||
try { findings = JSON.parse(row.findings_json || '[]'); } catch (_) { /* leave empty */ }
|
||||
resolve({ total: row.total || 0, findings, synced_at: row.synced_at, sync_status: row.sync_status, error_message: row.error_message });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function readNotes(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all('SELECT finding_id, note FROM ivanti_finding_notes', (err, rows) => {
|
||||
if (err) return reject(err);
|
||||
const map = {};
|
||||
(rows || []).forEach((r) => { map[r.finding_id] = r.note; });
|
||||
resolve(map);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readStateWithNotes(db) {
|
||||
const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
|
||||
state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' }));
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
function createIvantiFindingsRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
initTables(db)
|
||||
.then(() => scheduleSync(db))
|
||||
.catch((err) => console.error('[Ivanti Findings] Init failed:', err));
|
||||
|
||||
router.use(requireAuth(db));
|
||||
|
||||
// GET / — cached findings with notes merged in
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
res.json(await readStateWithNotes(db));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading findings' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /sync — trigger immediate sync, return fresh state
|
||||
router.post('/sync', async (req, res) => {
|
||||
await syncFindings(db);
|
||||
try {
|
||||
res.json(await readStateWithNotes(db));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
||||
router.put('/:findingId/note', (req, res) => {
|
||||
const { findingId } = req.params;
|
||||
const note = String(req.body.note || '').slice(0, 255);
|
||||
|
||||
db.run(
|
||||
`INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(finding_id) DO UPDATE SET note=excluded.note, updated_at=datetime('now')`,
|
||||
[findingId, note],
|
||||
(err) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to save note' });
|
||||
res.json({ finding_id: findingId, note });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createIvantiFindingsRouter;
|
||||
@@ -21,6 +21,7 @@ const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -183,6 +184,9 @@ app.use('/api/archer-tickets', createArcherTicketsRouter(db));
|
||||
// Ivanti / RiskSense workflow routes (all authenticated users)
|
||||
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
|
||||
|
||||
// Ivanti / RiskSense host findings routes (all authenticated users)
|
||||
app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
|
||||
|
||||
// ========== CVE ENDPOINTS ==========
|
||||
|
||||
// Get all CVEs with optional filters (authenticated users)
|
||||
|
||||
@@ -1,25 +1,399 @@
|
||||
import React from 'react';
|
||||
import { BarChart2 } from 'lucide-react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
export default function ReportingPage() {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem',
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}>
|
||||
<BarChart2 style={{ width: '36px', height: '36px', color: '#F59E0B' }} />
|
||||
</div>
|
||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>
|
||||
Reporting
|
||||
</h2>
|
||||
<p style={{ color: '#475569', fontSize: '0.875rem', fontFamily: 'monospace' }}>
|
||||
Under construction — coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ReportingPage() {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user