diff --git a/backend/db-schema.sql b/backend/db-schema.sql
index e1226f3..bd46d1b 100644
--- a/backend/db-schema.sql
+++ b/backend/db-schema.sql
@@ -248,6 +248,17 @@ CREATE TABLE IF NOT EXISTS ivanti_counts_history (
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
-- =============================================================================
diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js
index de9e7ea..6ffe5ea 100644
--- a/backend/routes/ivantiFindings.js
+++ b/backend/routes/ivantiFindings.js
@@ -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,
diff --git a/backend/server.js b/backend/server.js
index 3b357e0..688b6d3 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -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
app.listen(PORT, () => {
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
diff --git a/frontend/src/components/pages/IvantiCountsChart.js b/frontend/src/components/pages/IvantiCountsChart.js
index 62cb186..3f3473f 100644
--- a/frontend/src/components/pages/IvantiCountsChart.js
+++ b/frontend/src/components/pages/IvantiCountsChart.js
@@ -188,7 +188,7 @@ function extractDate(ts) {
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
-export default function IvantiCountsChart() {
+export default function IvantiCountsChart({ teamsParam }) {
const [collapsed, setCollapsed] = useState(false);
const [loading, setLoading] = useState(true);
const [history, setHistory] = useState([]);
@@ -199,8 +199,11 @@ export default function IvantiCountsChart() {
const load = async () => {
setLoading(true);
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([
- fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' }),
+ fetch(historyUrl, { credentials: 'include' }),
fetch(`${API_BASE}/ivanti/findings/anomaly/history`, { credentials: 'include' }),
]);
if (!cancelled) {
@@ -218,7 +221,7 @@ export default function IvantiCountsChart() {
};
load();
return () => { cancelled = true; };
- }, []);
+ }, [teamsParam]);
const chartData = useMemo(
() => history.map(r => ({ ...r, date: fmtDate(r.date) })),
diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js
index c811aee..86570cf 100644
--- a/frontend/src/components/pages/ReportingPage.js
+++ b/frontend/src/components/pages/ReportingPage.js
@@ -5187,8 +5187,16 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
}, []); // eslint-disable-line
// Re-fetch counts when admin scope changes (per-BU counts from Postgres)
+ // Silent fetch — no loading spinner, just update the numbers
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
// 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
---------------------------------------------------------------- */}
{metricsTab === 'ivanti' && }
- {metricsTab === 'ivanti' && }
+ {metricsTab === 'ivanti' && }
{/* ----------------------------------------------------------------
Panel 2 — Findings table