feat(triage): Ivanti findings trend chart + rename Reporting to Vulnerability Triage
Add time-based open/closed tracking for Ivanti findings (Tier 2 from
the reporting recommendations doc) and rename the Reporting page to
Vulnerability Triage to better reflect its purpose.
Backend — ivantiFindings.js:
- Create ivanti_counts_history table (appended on every sync, never
overwritten — Option B from design discussion)
- INSERT snapshot after each successful syncClosedCount() call
- GET /api/ivanti/findings/counts/history endpoint — returns last
snapshot per calendar day using ROW_NUMBER window function, so
multiple daily syncs collapse to the end-of-day value
Frontend:
- New IvantiCountsChart component: collapsible dual-line chart
(open vs closed) with dark tooltip, delta label showing change
since previous day, and graceful no-data states
- Chart placed between the donut metrics panel and the findings table
on the Vulnerability Triage page
- Renamed page: 'reporting' → 'triage' (page ID, nav label, component
export, all cross-file references)
- ComplianceDetailPanel "View in Reporting" link updated to "View in
Triage" and navigates to the correct page ID
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -175,6 +175,15 @@ function initTables(db) {
|
|||||||
db.run(`
|
db.run(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
||||||
ON ivanti_finding_overrides(finding_id)
|
ON ivanti_finding_overrides(finding_id)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
open_count INTEGER NOT NULL,
|
||||||
|
closed_count INTEGER NOT NULL,
|
||||||
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
`, (err) => {
|
`, (err) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve();
|
else resolve();
|
||||||
@@ -271,6 +280,14 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
|||||||
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
[openCount, closedCount]
|
[openCount, closedCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Append a snapshot to history — every sync is stored; the history
|
||||||
|
// endpoint aggregates to last-per-day at query time (Option B).
|
||||||
|
await dbRun(db,
|
||||||
|
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`,
|
||||||
|
[openCount, closedCount]
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
||||||
@@ -576,6 +593,33 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /counts/history — last snapshot per day, ascending, for the trend chart.
|
||||||
|
// Uses a window function (ROW_NUMBER) to pick the final sync of each calendar day.
|
||||||
|
router.get('/counts/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT date, open_count, closed_count FROM (
|
||||||
|
SELECT DATE(recorded_at) AS date,
|
||||||
|
open_count, closed_count,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY DATE(recorded_at)
|
||||||
|
ORDER BY recorded_at DESC
|
||||||
|
) AS rn
|
||||||
|
FROM ivanti_counts_history
|
||||||
|
) WHERE rn = 1
|
||||||
|
ORDER BY date ASC`,
|
||||||
|
[],
|
||||||
|
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
res.json({ history: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] GET /counts/history error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
||||||
router.get('/fp-workflow-counts', async (req, res) => {
|
router.get('/fp-workflow-counts', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import KnowledgeBaseModal from './components/KnowledgeBaseModal';
|
|||||||
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
||||||
import NavDrawer from './components/NavDrawer';
|
import NavDrawer from './components/NavDrawer';
|
||||||
import CalendarWidget from './components/CalendarWidget';
|
import CalendarWidget from './components/CalendarWidget';
|
||||||
import ReportingPage from './components/pages/ReportingPage';
|
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
||||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||||
import ExportsPage from './components/pages/ExportsPage';
|
import ExportsPage from './components/pages/ExportsPage';
|
||||||
import CompliancePage from './components/pages/CompliancePage';
|
import CompliancePage from './components/pages/CompliancePage';
|
||||||
@@ -966,14 +966,14 @@ export default function App() {
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onNavigate={(page) => {
|
onNavigate={(page) => {
|
||||||
// Clear contextual filters when navigating directly via the nav drawer
|
// Clear contextual filters when navigating directly via the nav drawer
|
||||||
if (page === 'reporting') { setCalendarFilter(null); setReportingExcFilter(null); }
|
if (page === 'triage') { setCalendarFilter(null); setReportingExcFilter(null); }
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Scanning line effect */}
|
{/* Scanning line effect */}
|
||||||
<div className="scan-line"></div>
|
<div className="scan-line"></div>
|
||||||
|
|
||||||
<div className={`${currentPage === 'reporting' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
|
<div className={`${currentPage === 'triage' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex justify-between items-start mb-6">
|
||||||
@@ -1043,7 +1043,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
{currentPage === 'triage' && <VulnerabilityTriagePage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||||
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
||||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
{currentPage === 'exports' && <ExportsPage />}
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
@@ -2231,7 +2231,7 @@ export default function App() {
|
|||||||
<CalendarWidget
|
<CalendarWidget
|
||||||
onDateClick={(dateStr) => {
|
onDateClick={(dateStr) => {
|
||||||
setCalendarFilter(dateStr);
|
setCalendarFilter(dateStr);
|
||||||
setCurrentPage('reporting');
|
setCurrentPage('triage');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-reac
|
|||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||||
{ id: 'reporting', label: 'Reporting', icon: BarChart2, color: '#F59E0B', description: 'Reports & analytics' },
|
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
|
||||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
||||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||||
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
|||||||
</div>
|
</div>
|
||||||
{onNavigate && (
|
{onNavigate && (
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); onNavigate('reporting'); }}
|
onClick={e => { e.stopPropagation(); onNavigate('triage'); }}
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0, marginLeft: '0.5rem',
|
flexShrink: 0, marginLeft: '0.5rem',
|
||||||
background: 'rgba(14,165,233,0.1)',
|
background: 'rgba(14,165,233,0.1)',
|
||||||
@@ -348,7 +348,7 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
|||||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.18)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.7)'; }}
|
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.18)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.7)'; }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.4)'; }}
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.4)'; }}
|
||||||
>
|
>
|
||||||
View in Reporting →
|
View in Triage →
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
207
frontend/src/components/pages/IvantiCountsChart.js
Normal file
207
frontend/src/components/pages/IvantiCountsChart.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// IvantiCountsChart.js
|
||||||
|
// Collapsible trend panel for the Vulnerability Triage page.
|
||||||
|
// Shows open vs closed Ivanti finding counts over time (last sync per day).
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
LineChart, Line,
|
||||||
|
XAxis, YAxis, CartesianGrid,
|
||||||
|
Tooltip, Legend, ReferenceLine,
|
||||||
|
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 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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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 (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(10,17,32,0.97)',
|
||||||
|
border: '1px solid rgba(245,158,11,0.3)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
minWidth: '160px',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: AMBER, marginBottom: '0.35rem', fontWeight: '700', fontSize: '0.65rem' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{payload.map(p => (
|
||||||
|
<div key={p.dataKey} style={{ color: p.color, marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span style={{ opacity: 0.8 }}>{p.name}</span>
|
||||||
|
<span style={{ fontWeight: '700' }}>{p.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{openVal != null && closedVal != null && (
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', marginTop: '0.35rem', paddingTop: '0.3rem', color: '#475569', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span>total</span>
|
||||||
|
<span style={{ color: '#64748B', fontWeight: '600' }}>{openVal + closedVal}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function IvantiCountsChart() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' });
|
||||||
|
if (res.ok && !cancelled) {
|
||||||
|
const d = await res.json();
|
||||||
|
setHistory(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]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
|
|
||||||
|
{/* ── Header ────────────────────────────────────────────────── */}
|
||||||
|
<button
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<TrendingUp style={{ width: '14px', height: '14px', color: AMBER }} />
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
|
||||||
|
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
}}>
|
||||||
|
Findings Trend
|
||||||
|
</span>
|
||||||
|
{loading && (
|
||||||
|
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
|
||||||
|
)}
|
||||||
|
{!loading && deltaLabel && (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: deltaLabel.color, marginLeft: '0.25rem' }}>
|
||||||
|
{deltaLabel.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{collapsed
|
||||||
|
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||||
|
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||||
|
border: '1px solid rgba(245,158,11,0.15)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '1rem 1.25rem 0.875rem',
|
||||||
|
}}>
|
||||||
|
<div style={{ marginBottom: '0.625rem', display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||||
|
Open vs Closed — end-of-day snapshot per sync day
|
||||||
|
</div>
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||||||
|
{chartData.length} day{chartData.length !== 1 ? 's' : ''} of data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chartData.length < 2 ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
height: '160px', color: '#334155',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
border: '1px dashed rgba(245,158,11,0.1)', borderRadius: '0.375rem',
|
||||||
|
}}>
|
||||||
|
{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'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||||
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||||
|
<Line
|
||||||
|
type="monotone" dataKey="open_count" name="Open"
|
||||||
|
stroke={AMBER} strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: AMBER, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone" dataKey="closed_count" name="Closed"
|
||||||
|
stroke={SKY} strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: SKY, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
|
|||||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react';
|
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import IvantiCountsChart from './IvantiCountsChart';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const STORAGE_KEY = 'steam_findings_columns_v2';
|
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||||
@@ -1536,7 +1537,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main ReportingPage
|
// Main ReportingPage
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export default function ReportingPage({ filterDate, filterEXC }) {
|
export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||||
const { canWrite } = useAuth();
|
const { canWrite } = useAuth();
|
||||||
const [findings, setFindings] = useState([]);
|
const [findings, setFindings] = useState([]);
|
||||||
const [total, setTotal] = useState(null);
|
const [total, setTotal] = useState(null);
|
||||||
@@ -1965,6 +1966,11 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ----------------------------------------------------------------
|
||||||
|
Panel 1.5 — Open vs Closed trend over time
|
||||||
|
---------------------------------------------------------------- */}
|
||||||
|
<IvantiCountsChart />
|
||||||
|
|
||||||
{/* ----------------------------------------------------------------
|
{/* ----------------------------------------------------------------
|
||||||
Panel 2 — Findings table
|
Panel 2 — Findings table
|
||||||
---------------------------------------------------------------- */}
|
---------------------------------------------------------------- */}
|
||||||
|
|||||||
Reference in New Issue
Block a user