fix: resolve 5 pre-merge issues in finding archive tracking
1. ACTIVE state never populated — stats endpoint now computes ACTIVE from live findings cache count instead of querying archive table 2. CHECK constraint mismatch — migration now uses 3-state constraint (ARCHIVED, RETURNED, CLOSED) matching runtime initArchiveTables() 3. Archive filter click non-functional — handleArchiveStateClick now fetches and renders filtered archive list below summary bar 4. Hook glob pattern mismatch — changed **/migrate*.js to **/migrations/*.js so hook fires for actual migration filenames 5. Stale stats after sync — ArchiveSummaryBar polls every 60s and refreshes immediately after workflow sync via refreshKey prop
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": "Verify New Migration",
|
"name": "Verify New Migration",
|
||||||
"description": "On creation of new migration files (migrate*.js), verifies the migration follows existing project patterns: uses CREATE TABLE IF NOT EXISTS, includes explicit column types, adds appropriate indexes, and wraps multiple statements in transactions.",
|
"description": "On creation of new migration files in backend/migrations/, verifies the migration follows existing project patterns: uses CREATE TABLE IF NOT EXISTS, includes explicit column types, adds appropriate indexes, and wraps multiple statements in transactions.",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"when": {
|
"when": {
|
||||||
"type": "fileCreated",
|
"type": "fileCreated",
|
||||||
"patterns": [
|
"patterns": [
|
||||||
"**/migrate*.js"
|
"**/migrations/*.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"then": {
|
"then": {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ db.serialize(() => {
|
|||||||
finding_title TEXT NOT NULL DEFAULT '',
|
finding_title TEXT NOT NULL DEFAULT '',
|
||||||
host_name TEXT NOT NULL DEFAULT '',
|
host_name TEXT NOT NULL DEFAULT '',
|
||||||
ip_address TEXT NOT NULL DEFAULT '',
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
current_state TEXT NOT NULL CHECK(current_state IN ('ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED')),
|
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED', 'RETURNED', 'CLOSED')),
|
||||||
last_severity REAL NOT NULL DEFAULT 0,
|
last_severity REAL NOT NULL DEFAULT 0,
|
||||||
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ function createIvantiArchiveRouter(db, requireAuth) {
|
|||||||
// All routes require authentication
|
// All routes require authentication
|
||||||
router.use(requireAuth(db));
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
// GET / — List archive records with optional ?state= filter
|
/**
|
||||||
|
* GET /
|
||||||
|
* List archive records with optional state filtering.
|
||||||
|
*
|
||||||
|
* @query {string} [state] - Filter by lifecycle state (ACTIVE, ARCHIVED, RETURNED, CLOSED)
|
||||||
|
* @returns {Object} 200 - { archives: Array<ArchiveRecord>, total: number }
|
||||||
|
* @returns {Object} 400 - { error: string } when state param is invalid
|
||||||
|
* @returns {Object} 500 - { error: string } on database failure
|
||||||
|
*/
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const { state } = req.query;
|
const { state } = req.query;
|
||||||
|
|
||||||
@@ -44,9 +52,17 @@ function createIvantiArchiveRouter(db, requireAuth) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /stats — Summary counts by state
|
/**
|
||||||
|
* GET /stats
|
||||||
|
* Summary counts of archive records by lifecycle state.
|
||||||
|
* ACTIVE is implicit: live findings in the cache that have no ARCHIVED/RETURNED archive record.
|
||||||
|
*
|
||||||
|
* @returns {Object} 200 - { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
|
||||||
|
* @returns {Object} 500 - { error: string } on database failure
|
||||||
|
*/
|
||||||
router.get('/stats', async (req, res) => {
|
router.get('/stats', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// Count archive records by state
|
||||||
const rows = await new Promise((resolve, reject) => {
|
const rows = await new Promise((resolve, reject) => {
|
||||||
db.all(
|
db.all(
|
||||||
`SELECT current_state, COUNT(*) as count
|
`SELECT current_state, COUNT(*) as count
|
||||||
@@ -60,15 +76,31 @@ function createIvantiArchiveRouter(db, requireAuth) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 };
|
const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 };
|
||||||
let total = 0;
|
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (stats.hasOwnProperty(row.current_state)) {
|
if (stats.hasOwnProperty(row.current_state)) {
|
||||||
stats[row.current_state] = row.count;
|
stats[row.current_state] = row.count;
|
||||||
}
|
}
|
||||||
total += row.count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute ACTIVE: total live findings minus those with ARCHIVED or RETURNED records
|
||||||
|
const cacheRow = await new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT total FROM ivanti_findings_cache WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const liveFindingsCount = (cacheRow && cacheRow.total) || 0;
|
||||||
|
// Findings that are ARCHIVED or RETURNED are "missing" from the live set,
|
||||||
|
// so ACTIVE = live count (all findings currently present in sync results)
|
||||||
|
stats.ACTIVE = liveFindingsCount;
|
||||||
|
|
||||||
|
const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED;
|
||||||
|
|
||||||
res.json({ ...stats, total });
|
res.json({ ...stats, total });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Archive stats error:', err);
|
console.error('Archive stats error:', err);
|
||||||
@@ -76,7 +108,15 @@ function createIvantiArchiveRouter(db, requireAuth) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:findingId/history — Transition history for a finding
|
/**
|
||||||
|
* GET /:findingId/history
|
||||||
|
* Transition history for a specific archived finding, ordered by most recent first.
|
||||||
|
* Returns an empty transitions array if the finding has no archive record.
|
||||||
|
*
|
||||||
|
* @param {string} findingId - Ivanti finding identifier (route param)
|
||||||
|
* @returns {Object} 200 - { finding_id: string, transitions: Array<TransitionRecord> }
|
||||||
|
* @returns {Object} 500 - { error: string } on database failure
|
||||||
|
*/
|
||||||
router.get('/:findingId/history', async (req, res) => {
|
router.get('/:findingId/history', async (req, res) => {
|
||||||
const { findingId } = req.params;
|
const { findingId } = req.params;
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ export default function App() {
|
|||||||
|
|
||||||
// Archive filter state
|
// Archive filter state
|
||||||
const [archiveFilter, setArchiveFilter] = useState(null);
|
const [archiveFilter, setArchiveFilter] = useState(null);
|
||||||
|
const [archiveRefreshKey, setArchiveRefreshKey] = useState(0);
|
||||||
|
const [archiveList, setArchiveList] = useState([]);
|
||||||
|
const [archiveListLoading, setArchiveListLoading] = useState(false);
|
||||||
|
|
||||||
const toggleCVEExpand = (cveId) => {
|
const toggleCVEExpand = (cveId) => {
|
||||||
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
||||||
@@ -370,11 +373,23 @@ export default function App() {
|
|||||||
console.error('Error syncing Ivanti workflows:', err);
|
console.error('Error syncing Ivanti workflows:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIvantiSyncing(false);
|
setIvantiSyncing(false);
|
||||||
|
setArchiveRefreshKey(k => k + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchiveStateClick = (state) => {
|
const handleArchiveStateClick = (state) => {
|
||||||
setArchiveFilter(prev => prev === state ? null : state);
|
const newFilter = archiveFilter === state ? null : state;
|
||||||
|
setArchiveFilter(newFilter);
|
||||||
|
if (newFilter) {
|
||||||
|
setArchiveListLoading(true);
|
||||||
|
fetch(`${API_BASE}/ivanti/archive?state=${newFilter}`, { credentials: 'include' })
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject())
|
||||||
|
.then(data => setArchiveList(data.archives || []))
|
||||||
|
.catch(() => setArchiveList([]))
|
||||||
|
.finally(() => setArchiveListLoading(false));
|
||||||
|
} else {
|
||||||
|
setArchiveList([]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchDocuments = async (cveId, vendor) => {
|
const fetchDocuments = async (cveId, vendor) => {
|
||||||
@@ -2260,7 +2275,47 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Archive Summary Bar */}
|
{/* Archive Summary Bar */}
|
||||||
<ArchiveSummaryBar onStateClick={handleArchiveStateClick} activeFilter={archiveFilter} />
|
<ArchiveSummaryBar onStateClick={handleArchiveStateClick} activeFilter={archiveFilter} refreshKey={archiveRefreshKey} />
|
||||||
|
|
||||||
|
{/* Archive list — shown when a state card is clicked */}
|
||||||
|
{archiveFilter && (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{archiveFilter} findings
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { setArchiveFilter(null); setArchiveList([]); }}
|
||||||
|
style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||||
|
>
|
||||||
|
✕ Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{archiveListLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem' }}>Loading…</div>
|
||||||
|
) : archiveList.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem', border: '1px dashed rgba(100, 116, 139, 0.3)', borderRadius: '0.375rem' }}>
|
||||||
|
No {archiveFilter.toLowerCase()} findings
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ maxHeight: '240px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||||
|
{archiveList.map((a) => (
|
||||||
|
<div key={a.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))', border: '1px solid rgba(100, 116, 139, 0.25)', borderRadius: '0.375rem', padding: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600', color: '#E2E8F0' }}>{a.finding_title || a.finding_id}</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', padding: '0.15rem 0.35rem', borderRadius: '0.25rem', background: 'rgba(100, 116, 139, 0.2)', border: '1px solid rgba(100, 116, 139, 0.4)', color: '#94A3B8', whiteSpace: 'nowrap' }}>
|
||||||
|
{a.last_severity?.toFixed(1) ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B' }}>
|
||||||
|
{a.host_name}{a.ip_address ? ` (${a.ip_address})` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{ivantiLoading ? (
|
{ivantiLoading ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ function hexToRgb(hex) {
|
|||||||
return `${r}, ${g}, ${b}`;
|
return `${r}, ${g}, ${b}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArchiveSummaryBar({ onStateClick, activeFilter }) {
|
export default function ArchiveSummaryBar({ onStateClick, activeFilter, refreshKey }) {
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
@@ -146,8 +146,11 @@ export default function ArchiveSummaryBar({ onStateClick, activeFilter }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, []);
|
// Re-fetch every 60s so stats stay reasonably fresh after syncs
|
||||||
|
const interval = setInterval(load, 60000);
|
||||||
|
return () => { cancelled = true; clearInterval(interval); };
|
||||||
|
}, [refreshKey]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user