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:
@@ -248,6 +248,17 @@ CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
|||||||
recorded_at TIMESTAMPTZ DEFAULT NOW()
|
recorded_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_history_by_bu (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
bu_ownership TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL CHECK (state IN ('open', 'closed')),
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
recorded_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_counts_history_bu ON ivanti_counts_history_by_bu(bu_ownership);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_counts_history_bu_date ON ivanti_counts_history_by_bu(recorded_at);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- Ivanti FP (False Positive) submissions
|
-- Ivanti FP (False Positive) submissions
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|||||||
@@ -529,6 +529,19 @@ async function syncClosedCount(openCount, apiKey, clientId, skipTls) {
|
|||||||
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES ($1, $2)`,
|
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES ($1, $2)`,
|
||||||
[openCount, closedCount]
|
[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)' : ''}`);
|
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
|
* GET /api/ivanti/findings/counts/history
|
||||||
*
|
*
|
||||||
* Return the last snapshot per day (ascending) for the trend chart.
|
* 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} 200 - { history: Array<{ date: string, open_count: number, closed_count: number }> }
|
||||||
* @returns {Object} 500 - { error: string } on database error
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
*/
|
*/
|
||||||
router.get('/counts/history', async (req, res) => {
|
router.get('/counts/history', async (req, res) => {
|
||||||
try {
|
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(
|
const { rows } = await pool.query(
|
||||||
`SELECT date, open_count, closed_count FROM (
|
`SELECT date, open_count, closed_count FROM (
|
||||||
SELECT recorded_at::date AS date,
|
SELECT recorded_at::date AS date,
|
||||||
|
|||||||
@@ -1156,6 +1156,20 @@ app.get('/api/stats', requireAuth(), async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve frontend build (for testing/production — serves React SPA)
|
||||||
|
const frontendBuild = path.join(__dirname, '..', 'frontend', 'build');
|
||||||
|
if (fs.existsSync(frontendBuild)) {
|
||||||
|
app.use(express.static(frontendBuild));
|
||||||
|
// SPA fallback — serve index.html for any non-API route
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (!req.path.startsWith('/api/') && !req.path.startsWith('/uploads/')) {
|
||||||
|
res.sendFile(path.join(frontendBuild, 'index.html'));
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ function extractDate(ts) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main component
|
// Main component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export default function IvantiCountsChart() {
|
export default function IvantiCountsChart({ teamsParam }) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [history, setHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
@@ -199,8 +199,11 @@ export default function IvantiCountsChart() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const historyUrl = teamsParam
|
||||||
|
? `${API_BASE}/ivanti/findings/counts/history?teams=${encodeURIComponent(teamsParam)}`
|
||||||
|
: `${API_BASE}/ivanti/findings/counts/history`;
|
||||||
const [countsRes, anomalyRes] = await Promise.all([
|
const [countsRes, anomalyRes] = await Promise.all([
|
||||||
fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' }),
|
fetch(historyUrl, { credentials: 'include' }),
|
||||||
fetch(`${API_BASE}/ivanti/findings/anomaly/history`, { credentials: 'include' }),
|
fetch(`${API_BASE}/ivanti/findings/anomaly/history`, { credentials: 'include' }),
|
||||||
]);
|
]);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -218,7 +221,7 @@ export default function IvantiCountsChart() {
|
|||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, [teamsParam]);
|
||||||
|
|
||||||
const chartData = useMemo(
|
const chartData = useMemo(
|
||||||
() => history.map(r => ({ ...r, date: fmtDate(r.date) })),
|
() => history.map(r => ({ ...r, date: fmtDate(r.date) })),
|
||||||
|
|||||||
@@ -5187,8 +5187,16 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
}, []); // eslint-disable-line
|
}, []); // eslint-disable-line
|
||||||
|
|
||||||
// Re-fetch counts when admin scope changes (per-BU counts from Postgres)
|
// Re-fetch counts when admin scope changes (per-BU counts from Postgres)
|
||||||
|
// Silent fetch — no loading spinner, just update the numbers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCounts();
|
const teamsParam = getActiveTeamsParam();
|
||||||
|
const url = teamsParam
|
||||||
|
? `${API_BASE}/ivanti/findings/counts?teams=${encodeURIComponent(teamsParam)}`
|
||||||
|
: `${API_BASE}/ivanti/findings/counts`;
|
||||||
|
fetch(url, { credentials: 'include' })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => { if (data) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 }); })
|
||||||
|
.catch(() => {});
|
||||||
}, [adminScope]); // eslint-disable-line
|
}, [adminScope]); // eslint-disable-line
|
||||||
|
|
||||||
// Set/clear a single column filter
|
// Set/clear a single column filter
|
||||||
@@ -5786,7 +5794,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
Panel 1.5 — Open vs Closed trend over time
|
Panel 1.5 — Open vs Closed trend over time
|
||||||
---------------------------------------------------------------- */}
|
---------------------------------------------------------------- */}
|
||||||
{metricsTab === 'ivanti' && <AnomalyBanner />}
|
{metricsTab === 'ivanti' && <AnomalyBanner />}
|
||||||
{metricsTab === 'ivanti' && <IvantiCountsChart />}
|
{metricsTab === 'ivanti' && <IvantiCountsChart teamsParam={getActiveTeamsParam()} />}
|
||||||
|
|
||||||
{/* ----------------------------------------------------------------
|
{/* ----------------------------------------------------------------
|
||||||
Panel 2 — Findings table
|
Panel 2 — Findings table
|
||||||
|
|||||||
Reference in New Issue
Block a user