feat: per-BU trend lines in counts history chart

- Create ivanti_counts_history_by_bu table (bu_ownership, state, count per sync)
- Sync writes per-BU snapshot alongside global history on each sync
- Seed table with current counts for immediate first data point
- GET /counts/history accepts ?teams param — queries per-BU table when filtered
- IvantiCountsChart accepts teamsParam prop, re-fetches on scope change
- ReportingPage passes getActiveTeamsParam() to the chart
- Historical per-BU data accumulates from this point forward
- Global history (no filter) still uses the original aggregate table
This commit is contained in:
Jordan Ramos
2026-05-06 13:38:38 -06:00
parent 77f113e9ae
commit 573903a885
5 changed files with 87 additions and 5 deletions

View File

@@ -529,6 +529,19 @@ async function syncClosedCount(openCount, apiKey, clientId, skipTls) {
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES ($1, $2)`,
[openCount, closedCount]
);
// Per-BU history snapshot — enables scoped trend lines
try {
await pool.query(`
INSERT INTO ivanti_counts_history_by_bu (bu_ownership, state, count)
SELECT bu_ownership, state, COUNT(*)
FROM ivanti_findings
WHERE bu_ownership != ''
GROUP BY bu_ownership, state
`);
} catch (err) {
console.error('[Ivanti Findings] Per-BU history snapshot failed (non-fatal):', err.message);
}
}
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}${skipHistory ? ' (history write skipped — drift guard)' : ''}`);
@@ -1146,12 +1159,45 @@ function createIvantiFindingsRouter(db, requireAuth) {
* GET /api/ivanti/findings/counts/history
*
* Return the last snapshot per day (ascending) for the trend chart.
* Accepts optional `teams` query parameter to scope the trend to specific BUs.
* When teams is provided, uses the per-BU history table.
* When no teams, returns the global aggregate history.
*
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
* @returns {Object} 200 - { history: Array<{ date: string, open_count: number, closed_count: number }> }
* @returns {Object} 500 - { error: string } on database error
*/
router.get('/counts/history', async (req, res) => {
try {
const teamsParam = req.query.teams;
if (teamsParam) {
// Per-BU history — filter and aggregate by selected teams
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
if (teams.length > 0) {
const patterns = teams.map(t => `%${t}%`);
const { rows } = await pool.query(
`SELECT date,
SUM(CASE WHEN state = 'open' THEN count ELSE 0 END)::int AS open_count,
SUM(CASE WHEN state = 'closed' THEN count ELSE 0 END)::int AS closed_count
FROM (
SELECT recorded_at::date AS date, bu_ownership, state, count,
ROW_NUMBER() OVER (
PARTITION BY recorded_at::date, bu_ownership, state
ORDER BY recorded_at DESC
) AS rn
FROM ivanti_counts_history_by_bu
WHERE bu_ownership ILIKE ANY($1::text[])
) sub WHERE rn = 1
GROUP BY date
ORDER BY date ASC`,
[patterns]
);
return res.json({ history: rows });
}
}
// Global history (no filter)
const { rows } = await pool.query(
`SELECT date, open_count, closed_count FROM (
SELECT recorded_at::date AS date,