From d1fe0bf455dbd24bf32b5fb9b2d6d3f7b4ae792f Mon Sep 17 00:00:00 2001 From: jramos Date: Fri, 3 Apr 2026 15:51:18 -0600 Subject: [PATCH] fix: resolve 5 pre-merge issues in finding archive tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .kiro/hooks/verify-new-migration.kiro.hook | 4 +- .../migrations/add_finding_archive_tables.js | 2 +- backend/routes/ivantiArchive.js | 50 +++++++++++++-- frontend/src/App.js | 61 ++++++++++++++++++- .../src/components/pages/ArchiveSummaryBar.js | 9 ++- 5 files changed, 112 insertions(+), 14 deletions(-) diff --git a/.kiro/hooks/verify-new-migration.kiro.hook b/.kiro/hooks/verify-new-migration.kiro.hook index 5d83e7c..a6e2468 100644 --- a/.kiro/hooks/verify-new-migration.kiro.hook +++ b/.kiro/hooks/verify-new-migration.kiro.hook @@ -1,12 +1,12 @@ { "enabled": true, "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", "when": { "type": "fileCreated", "patterns": [ - "**/migrate*.js" + "**/migrations/*.js" ] }, "then": { diff --git a/backend/migrations/add_finding_archive_tables.js b/backend/migrations/add_finding_archive_tables.js index 77fa45b..e17310b 100644 --- a/backend/migrations/add_finding_archive_tables.js +++ b/backend/migrations/add_finding_archive_tables.js @@ -16,7 +16,7 @@ db.serialize(() => { finding_title TEXT NOT NULL DEFAULT '', host_name 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, first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/backend/routes/ivantiArchive.js b/backend/routes/ivantiArchive.js index 69a0f19..1630b50 100644 --- a/backend/routes/ivantiArchive.js +++ b/backend/routes/ivantiArchive.js @@ -9,7 +9,15 @@ function createIvantiArchiveRouter(db, requireAuth) { // All routes require authentication 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, 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) => { 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) => { try { + // Count archive records by state const rows = await new Promise((resolve, reject) => { db.all( `SELECT current_state, COUNT(*) as count @@ -60,15 +76,31 @@ function createIvantiArchiveRouter(db, requireAuth) { }); const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 }; - let total = 0; for (const row of rows) { if (stats.hasOwnProperty(row.current_state)) { 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 }); } catch (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 } + * @returns {Object} 500 - { error: string } on database failure + */ router.get('/:findingId/history', async (req, res) => { const { findingId } = req.params; diff --git a/frontend/src/App.js b/frontend/src/App.js index 7b7c7d9..6c7f02a 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -236,6 +236,9 @@ export default function App() { // Archive filter state const [archiveFilter, setArchiveFilter] = useState(null); + const [archiveRefreshKey, setArchiveRefreshKey] = useState(0); + const [archiveList, setArchiveList] = useState([]); + const [archiveListLoading, setArchiveListLoading] = useState(false); const toggleCVEExpand = (cveId) => { setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] })); @@ -370,11 +373,23 @@ export default function App() { console.error('Error syncing Ivanti workflows:', err); } finally { setIvantiSyncing(false); + setArchiveRefreshKey(k => k + 1); } - }; + }; 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) => { @@ -2260,7 +2275,47 @@ export default function App() { {/* Archive Summary Bar */} - + + + {/* Archive list — shown when a state card is clicked */} + {archiveFilter && ( +
+
+ + {archiveFilter} findings + + +
+ {archiveListLoading ? ( +
Loading…
+ ) : archiveList.length === 0 ? ( +
+ No {archiveFilter.toLowerCase()} findings +
+ ) : ( +
+ {archiveList.map((a) => ( +
+
+ {a.finding_title || a.finding_id} + + {a.last_severity?.toFixed(1) ?? '—'} + +
+
+ {a.host_name}{a.ip_address ? ` (${a.ip_address})` : ''} +
+
+ ))} +
+ )} +
+ )} {ivantiLoading ? (
diff --git a/frontend/src/components/pages/ArchiveSummaryBar.js b/frontend/src/components/pages/ArchiveSummaryBar.js index 6c68d30..85854bf 100644 --- a/frontend/src/components/pages/ArchiveSummaryBar.js +++ b/frontend/src/components/pages/ArchiveSummaryBar.js @@ -121,7 +121,7 @@ function hexToRgb(hex) { return `${r}, ${g}, ${b}`; } -export default function ArchiveSummaryBar({ onStateClick, activeFilter }) { +export default function ArchiveSummaryBar({ onStateClick, activeFilter, refreshKey }) { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); @@ -146,8 +146,11 @@ export default function ArchiveSummaryBar({ onStateClick, activeFilter }) { } }; 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) { return (