// IvantiCountsChart.js // Collapsible trend panel for the Vulnerability Triage page. // Shows open vs closed Ivanti finding counts over time (last sync per day), // with a separate sparkline row for archived/returned finding activity. import React, { useState, useEffect, useMemo } from 'react'; import { LineChart, Line, BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from 'recharts'; import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const AMBER = '#F59E0B'; const SKY = '#0EA5E9'; const GREEN = '#10B981'; const RED = '#EF4444'; const ROSE = '#F43F5E'; const TEAL = '#14B8A6'; const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' }; const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' }; const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' }; // --------------------------------------------------------------------------- // Custom dark tooltip — main trend chart // --------------------------------------------------------------------------- function DarkTooltip({ active, payload, label }) { if (!active || !payload?.length) return null; const openVal = payload.find(p => p.dataKey === 'open_count')?.value; const closedVal = payload.find(p => p.dataKey === 'closed_count')?.value; return (
{label}
{payload.map(p => (
{p.name} {p.value}
))} {openVal != null && closedVal != null && (
total {openVal + closedVal}
)}
); } // --------------------------------------------------------------------------- // Custom dark tooltip — archive activity sparkline // --------------------------------------------------------------------------- function ArchiveTooltip({ active, payload, label }) { if (!active || !payload?.length) return null; const archived = payload.find(p => p.dataKey === 'archived')?.value || 0; const returned = payload.find(p => p.dataKey === 'returned')?.value || 0; // Parse classification if present const dataPoint = payload[0]?.payload; const classification = dataPoint?.classification; return (
{label}
{archived > 0 && (
Archived {archived}
)} {returned > 0 && (
Returned {returned}
)} {archived === 0 && returned === 0 && (
No archive activity
)} {classification && archived > 0 && (
{classification.bu_reassignment > 0 && (
BU reassignment {classification.bu_reassignment}
)} {classification.severity_drift > 0 && (
Severity drift {classification.severity_drift}
)} {classification.closed_on_platform > 0 && (
Closed on platform {classification.closed_on_platform}
)} {classification.decommissioned > 0 && (
Decommissioned {classification.decommissioned}
)}
)}
); } // --------------------------------------------------------------------------- // Shorten YYYY-MM-DD to MM/DD/YY // --------------------------------------------------------------------------- function fmtDate(d) { if (!d) return ''; const p = d.split('-'); if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`; return d; } // Extract YYYY-MM-DD from a datetime string function extractDate(ts) { if (!ts) return ''; return ts.split('T')[0].split(' ')[0]; } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export default function IvantiCountsChart() { const [collapsed, setCollapsed] = useState(false); const [loading, setLoading] = useState(true); const [history, setHistory] = useState([]); const [anomalies, setAnomalies] = useState([]); useEffect(() => { let cancelled = false; const load = async () => { setLoading(true); try { const [countsRes, anomalyRes] = await Promise.all([ fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' }), fetch(`${API_BASE}/ivanti/findings/anomaly/history`, { credentials: 'include' }), ]); if (!cancelled) { if (countsRes.ok) { const d = await countsRes.json(); setHistory(d.history || []); } if (anomalyRes.ok) { const d = await anomalyRes.json(); setAnomalies(d.history || []); } } } catch { /* silent — chart shows no-data state */ } finally { if (!cancelled) setLoading(false); } }; load(); return () => { cancelled = true; }; }, []); const chartData = useMemo( () => history.map(r => ({ ...r, date: fmtDate(r.date) })), [history] ); // Build archive activity data aligned to the same date axis as the main chart. // Aggregate anomaly rows by date (take the last sync per day, matching the // counts history pattern), then merge onto the chartData date set. const archiveData = useMemo(() => { if (!anomalies.length || !chartData.length) return []; // Group anomalies by date, keep the latest per day const byDate = {}; for (const a of anomalies) { const rawDate = extractDate(a.sync_timestamp); const dateKey = fmtDate(rawDate); // anomaly/history returns newest first, so first seen per date is the latest if (!byDate[dateKey]) { byDate[dateKey] = a; } } // Map onto the chart date axis so both charts share the same X positions return chartData.map(point => { const anomaly = byDate[point.date]; if (anomaly) { return { date: point.date, archived: anomaly.newly_archived_count || 0, returned: anomaly.returned_count || 0, classification: anomaly.classification || {}, is_significant: anomaly.is_significant, }; } return { date: point.date, archived: 0, returned: 0, classification: {}, is_significant: false }; }); }, [anomalies, chartData]); // Check if there's any archive activity worth showing const hasArchiveActivity = useMemo( () => archiveData.some(d => d.archived > 0 || d.returned > 0), [archiveData] ); // Compute a simple delta label for the latest vs previous point const deltaLabel = useMemo(() => { if (chartData.length < 2) return null; const latest = chartData[chartData.length - 1]; const prev = chartData[chartData.length - 2]; const delta = latest.open_count - prev.open_count; if (delta === 0) return { text: 'no change in open', color: '#475569' }; if (delta < 0) return { text: `▼ ${Math.abs(delta)} open since ${prev.date}`, color: GREEN }; return { text: `▲ ${delta} open since ${prev.date}`, color: RED }; }, [chartData]); return (
{/* ── Header ────────────────────────────────────────────────── */} {!collapsed && (
Open vs Closed — end-of-day snapshot per sync day
{chartData.length > 0 && (
{chartData.length} day{chartData.length !== 1 ? 's' : ''} of data
)}
{chartData.length < 2 ? (
{chartData.length === 0 ? 'Trend data begins accumulating after the first sync — check back tomorrow' : 'Need at least 2 days of syncs to display a trend'}
) : ( <> {/* ── Main trend chart ──────────────────────── */} } /> {/* ── Archive activity sparkline ────────────── */} {hasArchiveActivity && (
Archive Activity
} /> {archiveData.map((entry, idx) => ( ))}
)} )}
)}
); }