// 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 ────────────────────────────────────────────────── */}
setCollapsed(c => !c)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer',
padding: '0 0 0.625rem 0',
borderBottom: collapsed ? 'none' : '1px solid rgba(245,158,11,0.1)',
marginBottom: collapsed ? 0 : '0.875rem',
}}
>
Findings Trend
{loading && (
)}
{!loading && deltaLabel && (
{deltaLabel.text}
)}
{collapsed
?
:
}
{!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) => (
|
))}
)}
>
)}
)}
);
}