feat(compliance): add time-based trend charts to Compliance page
Add 6 Recharts charts in a collapsible Historical Trends panel on the Compliance page, covering all Tier-1 recommendations from the reporting design doc. Backend — 5 new API endpoints: - GET /api/compliance/trends — active totals + per-team counts per upload - GET /api/compliance/mttr — mean days to resolution per team - GET /api/compliance/top-recurring — most persistent active findings by seen_count - GET /api/compliance/category-trend — category breakdown per upload (future use) - GET /api/archer-tickets/status-trend — ticket pipeline by creation date + status Frontend — new ComplianceChartsPanel component: - Active Findings Over Time (multi-line: total + per-team dashed) - Change per Report Cycle (stacked bar: new/recurring + resolved) - Team Compliance Health (multi-line per team) - Mean Time to Resolution (horizontal bar per team) - Most Persistent Findings (horizontal bar top-10 by seen_count) - Archer Exception Pipeline (stacked bar by date + status) All charts degrade gracefully to a no-data placeholder until uploads accumulate. Panel is collapsible to stay out of the way when not needed. Adds recharts dependency to frontend. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -584,6 +584,128 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /trends
|
||||
// Per-upload active totals + per-team counts for time-series charts.
|
||||
// Returns rows ordered ascending by report_date.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/trends', async (req, res) => {
|
||||
try {
|
||||
const uploads = await dbAll(db,
|
||||
`SELECT id, report_date,
|
||||
COALESCE(new_count, 0) AS new_count,
|
||||
COALESCE(recurring_count, 0) AS recurring_count,
|
||||
COALESCE(resolved_count, 0) AS resolved_count,
|
||||
COALESCE(new_count, 0) + COALESCE(recurring_count, 0) AS total_active
|
||||
FROM compliance_uploads
|
||||
ORDER BY report_date ASC`
|
||||
);
|
||||
|
||||
if (uploads.length === 0) return res.json({ trends: [] });
|
||||
|
||||
// Per-team active counts — items whose upload_id matches the upload
|
||||
// (recurring items have upload_id bumped each cycle, so this is accurate)
|
||||
const teamRows = await dbAll(db,
|
||||
`SELECT ci.upload_id, ci.team, COUNT(ci.id) AS count
|
||||
FROM compliance_items ci
|
||||
WHERE ci.team IS NOT NULL
|
||||
GROUP BY ci.upload_id, ci.team`
|
||||
);
|
||||
|
||||
const teamMap = {};
|
||||
teamRows.forEach(r => {
|
||||
if (!teamMap[r.upload_id]) teamMap[r.upload_id] = {};
|
||||
teamMap[r.upload_id][r.team] = r.count;
|
||||
});
|
||||
|
||||
const trends = uploads.map(u => ({
|
||||
report_date: u.report_date,
|
||||
new_count: u.new_count,
|
||||
recurring_count: u.recurring_count,
|
||||
resolved_count: u.resolved_count,
|
||||
total_active: u.total_active,
|
||||
STEAM: teamMap[u.id]?.STEAM || 0,
|
||||
'ACCESS-ENG': teamMap[u.id]?.['ACCESS-ENG'] || 0,
|
||||
'ACCESS-OPS': teamMap[u.id]?.['ACCESS-OPS'] || 0,
|
||||
INTELDEV: teamMap[u.id]?.INTELDEV || 0,
|
||||
}));
|
||||
|
||||
res.json({ trends });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /trends error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /mttr
|
||||
// Mean time to resolution (calendar days) per team, for resolved items.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/mttr', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT
|
||||
ci.team,
|
||||
ROUND(AVG(JULIANDAY(ru.report_date) - JULIANDAY(fu.report_date)), 1) AS avg_days,
|
||||
COUNT(*) AS resolved_count
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||
JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||
WHERE ci.resolved_upload_id IS NOT NULL
|
||||
AND fu.report_date IS NOT NULL
|
||||
AND ru.report_date IS NOT NULL
|
||||
GROUP BY ci.team
|
||||
ORDER BY avg_days DESC`
|
||||
);
|
||||
res.json({ mttr: rows });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /mttr error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /top-recurring
|
||||
// Active findings grouped by team + metric_id, sorted by seen_count desc.
|
||||
// Identifies chronic compliance gaps that keep reappearing.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/top-recurring', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT team, metric_id, metric_desc, seen_count, COUNT(*) AS host_count
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
GROUP BY team, metric_id
|
||||
ORDER BY seen_count DESC, host_count DESC
|
||||
LIMIT 20`
|
||||
);
|
||||
res.json({ items: rows });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /top-recurring error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /category-trend
|
||||
// Active item counts per category per upload, for stacked area chart.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/category-trend', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT cu.report_date, COALESCE(ci.category, 'Unknown') AS category, COUNT(ci.id) AS count
|
||||
FROM compliance_uploads cu
|
||||
JOIN compliance_items ci ON ci.upload_id = cu.id
|
||||
GROUP BY cu.id, category
|
||||
ORDER BY cu.report_date ASC`
|
||||
);
|
||||
res.json({ categoryTrend: rows });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /category-trend error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user