feat: implement finding archive tracking system

- Add migration script for ivanti_finding_archives and ivanti_archive_transitions tables
- Add archive detection logic (detectArchiveChanges, detectClosedFindings) in sync pipeline
- Add archive API router with list, stats, and history endpoints at /api/ivanti/archive
- Add ArchiveSummaryBar UI component with four state cards (ACTIVE, ARCHIVED, RETURNED, CLOSED)
- Integrate ArchiveSummaryBar into Ivanti findings page in App.js
- Register archive router in server.js
This commit is contained in:
jramos
2026-04-03 15:20:04 -06:00
parent 2b4ec5d8e2
commit 9bd5a52661
9 changed files with 699 additions and 28 deletions

View File

@@ -12,6 +12,7 @@ import VulnerabilityTriagePage from './components/pages/ReportingPage';
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
import ExportsPage from './components/pages/ExportsPage';
import CompliancePage from './components/pages/CompliancePage';
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
import './App.css';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -233,6 +234,9 @@ export default function App() {
const [ivantiLoading, setIvantiLoading] = useState(false);
const [ivantiSyncing, setIvantiSyncing] = useState(false);
// Archive filter state
const [archiveFilter, setArchiveFilter] = useState(null);
const toggleCVEExpand = (cveId) => {
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
};
@@ -369,6 +373,10 @@ export default function App() {
}
};
const handleArchiveStateClick = (state) => {
setArchiveFilter(prev => prev === state ? null : state);
};
const fetchDocuments = async (cveId, vendor) => {
const key = `${cveId}-${vendor}`;
if (cveDocuments[key]) return;
@@ -2251,6 +2259,9 @@ export default function App() {
: 'Never synced'}
</div>
{/* Archive Summary Bar */}
<ArchiveSummaryBar onStateClick={handleArchiveStateClick} activeFilter={archiveFilter} />
{ivantiLoading ? (
<div className="text-center py-8">
<Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" />

View File

@@ -0,0 +1,202 @@
// ArchiveSummaryBar.js
// Displays four stat cards for archive lifecycle states: ACTIVE, ARCHIVED, RETURNED, CLOSED.
// Fetches counts from /api/ivanti/archive/stats on mount.
import React, { useState, useEffect } from 'react';
import { Activity, Archive, RotateCcw, XCircle, Loader } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const STATE_CONFIG = [
{
key: 'ACTIVE',
label: 'Active',
color: '#0EA5E9',
Icon: Activity,
},
{
key: 'ARCHIVED',
label: 'Archived',
color: '#F59E0B',
Icon: Archive,
},
{
key: 'RETURNED',
label: 'Returned',
color: '#10B981',
Icon: RotateCcw,
},
{
key: 'CLOSED',
label: 'Closed',
color: '#EF4444',
Icon: XCircle,
},
];
function StatCard({ stateKey, label, color, Icon, count, active, onClick }) {
const [hovered, setHovered] = useState(false);
const isHighlighted = active || hovered;
const cardStyle = {
flex: '1 1 0',
minWidth: '140px',
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))',
border: `2px solid ${isHighlighted ? color : `rgba(${hexToRgb(color)}, 0.3)`}`,
borderRadius: '0.5rem',
padding: '1rem',
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: isHighlighted ? 'translateY(-2px)' : 'translateY(0)',
boxShadow: isHighlighted
? `0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(${hexToRgb(color)}, 0.25)`
: '0 4px 16px rgba(0, 0, 0, 0.5)',
position: 'relative',
overflow: 'hidden',
};
const accentLineStyle = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '2px',
background: `linear-gradient(90deg, transparent, ${color}, transparent)`,
boxShadow: `0 0 8px ${color}`,
};
return (
<div
style={cardStyle}
onClick={() => onClick(stateKey)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(stateKey); } }}
aria-label={`${label}: ${count} findings. ${active ? 'Currently filtered.' : 'Click to filter.'}`}
>
<div style={accentLineStyle} />
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.625rem' }}>
<Icon
style={{
width: '16px',
height: '16px',
color: color,
filter: isHighlighted ? `drop-shadow(0 0 4px ${color})` : 'none',
}}
/>
<span style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '0.7rem',
fontWeight: '600',
color: color,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textShadow: isHighlighted ? `0 0 8px rgba(${hexToRgb(color)}, 0.5)` : 'none',
}}>
{label}
</span>
</div>
<div style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '1.75rem',
fontWeight: '700',
color: '#F8FAFC',
lineHeight: 1,
textShadow: `0 0 16px rgba(${hexToRgb(color)}, 0.3)`,
}}>
{count != null ? count : '—'}
</div>
</div>
);
}
// Convert hex color to r, g, b string for use in rgba()
function hexToRgb(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `${r}, ${g}, ${b}`;
}
export default function ArchiveSummaryBar({ onStateClick, activeFilter }) {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
setError(false);
try {
const res = await fetch(`${API_BASE}/ivanti/archive/stats`, { credentials: 'include' });
if (res.ok && !cancelled) {
const data = await res.json();
setStats(data);
} else if (!cancelled) {
setError(true);
}
} catch {
if (!cancelled) setError(true);
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, []);
if (loading) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: '0.5rem', padding: '1.25rem',
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem',
}}>
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
Loading archive stats
</div>
);
}
if (error) {
return (
<div style={{
padding: '1rem', textAlign: 'center',
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem',
border: '1px dashed rgba(239, 68, 68, 0.2)', borderRadius: '0.375rem',
}}>
Unable to load archive statistics
</div>
);
}
const handleClick = (state) => {
if (onStateClick) onStateClick(state);
};
return (
<div style={{
display: 'flex',
gap: '0.75rem',
marginBottom: '1.25rem',
flexWrap: 'wrap',
}}>
{STATE_CONFIG.map(({ key, label, color, Icon }) => (
<StatCard
key={key}
stateKey={key}
label={label}
color={color}
Icon={Icon}
count={stats?.[key] ?? 0}
active={activeFilter === key}
onClick={handleClick}
/>
))}
</div>
);
}