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:
2026-04-02 10:12:04 -06:00
parent b111273e5a
commit 15ad207464
6 changed files with 266 additions and 9 deletions

View File

@@ -175,6 +175,15 @@ function initTables(db) {
db.run(`
CREATE INDEX IF NOT EXISTS idx_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) => {
if (err) reject(err);
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`,
[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}`);
} catch (err) {
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)
router.get('/fp-workflow-counts', async (req, res) => {
try {