// 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; const returnClassification = dataPoint?.return_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}
)}
)} {returnClassification && returned > 0 && (returnClassification.bu_reassignment > 0 || returnClassification.severity_drift > 0 || returnClassification.closed_on_platform > 0 || returnClassification.decommissioned > 0) && (
Returned because
{returnClassification.bu_reassignment > 0 && (
BU reassigned back {returnClassification.bu_reassignment}
)} {returnClassification.severity_drift > 0 && (
Severity re-escalated {returnClassification.severity_drift}
)} {returnClassification.closed_on_platform > 0 && (
Reopened on platform {returnClassification.closed_on_platform}
)} {returnClassification.decommissioned > 0 && (
Re-provisioned {returnClassification.decommissioned}
)}
)}
); } // --------------------------------------------------------------------------- // Shorten YYYY-MM-DD (or ISO datetime) to MM/DD/YY // --------------------------------------------------------------------------- function fmtDate(d) { if (!d) return ''; // Handle ISO datetime strings (e.g. "2026-05-12T06:00:00.000Z") — extract date part first const dateStr = String(d).split('T')[0]; const p = dateStr.split('-'); if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`; return dateStr; } // 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({ teamsParam }) { 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 historyUrl = teamsParam ? `${API_BASE}/ivanti/findings/counts/history?teams=${encodeURIComponent(teamsParam)}` : `${API_BASE}/ivanti/findings/counts/history`; const [countsRes, anomalyRes] = await Promise.all([ fetch(historyUrl, { 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; }; }, [teamsParam]); 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 — sum archived/returned counts and merge // classifications across all syncs that day, then align to the chartData dates. const archiveData = useMemo(() => { if (!anomalies.length || !chartData.length) return []; // Aggregate all anomaly rows per date (sum counts, merge classifications) const byDate = {}; for (const a of anomalies) { const rawDate = extractDate(a.sync_timestamp); const dateKey = fmtDate(rawDate); if (!byDate[dateKey]) { byDate[dateKey] = { archived: 0, returned: 0, classification: {}, return_classification: {}, is_significant: false, }; } const entry = byDate[dateKey]; entry.archived += (a.newly_archived_count || 0); entry.returned += (a.returned_count || 0); if (a.is_significant) entry.is_significant = true; // Merge classification counts for (const [key, val] of Object.entries(a.classification || {})) { entry.classification[key] = (entry.classification[key] || 0) + (val || 0); } for (const [key, val] of Object.entries(a.return_classification || {})) { entry.return_classification[key] = (entry.return_classification[key] || 0) + (val || 0); } } // Map onto the chart date axis so both charts share the same X positions return chartData.map(point => { const agg = byDate[point.date]; if (agg) { return { date: point.date, ...agg }; } return { date: point.date, archived: 0, returned: 0, classification: {}, return_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) => ( ))} {archiveData.map((entry, idx) => ( ))}
)} )}
)}
); }