From 356ce23462e16b2da0a46ea831de3800f21fc541 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Fri, 12 Jun 2026 12:12:59 -0600 Subject: [PATCH] Add BU reassignment from/to detail view in anomaly banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AnomalyBanner BU reassignment row is now clickable, expanding to show each affected finding with its host name and the team it moved from/to (e.g. STEAM → PIES). The backend bu-changes endpoint now supports optional since and limit query params to scope results to the relevant sync cycle. --- backend/routes/ivantiFindings.js | 36 ++++- .../src/components/pages/AnomalyBanner.js | 153 +++++++++++++++++- 2 files changed, 180 insertions(+), 9 deletions(-) diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 7d1c9af..791b93f 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -1433,18 +1433,40 @@ function createIvantiFindingsRouter(db, requireAuth) { /** * GET /api/ivanti/findings/bu-changes * - * Return all BU change events from ivanti_finding_bu_history. + * Return BU change events from ivanti_finding_bu_history. + * Accepts optional `since` to filter by date, or `limit` to cap the result count. + * If `since` is provided, returns all changes on or after that timestamp. + * If neither is provided, returns the most recent 200 rows (max 500). * - * @returns {Object} 200 - { changes: Array } + * @query {string} [since] - ISO timestamp; return changes where detected_at >= this value + * @query {string} [limit] - Maximum number of rows to return (default 200, max 500); ignored when `since` is provided + * @returns {Object} 200 - { changes: Array<{ id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at }> } * @returns {Object} 500 - { error: string } on database error */ router.get('/bu-changes', async (req, res) => { try { - const { rows } = await pool.query( - `SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at - FROM ivanti_finding_bu_history - ORDER BY detected_at DESC` - ); + const { since, limit } = req.query; + let rows; + if (since) { + const result = await pool.query( + `SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at + FROM ivanti_finding_bu_history + WHERE detected_at >= $1 + ORDER BY detected_at DESC`, + [since] + ); + rows = result.rows; + } else { + const maxRows = Math.min(parseInt(limit) || 200, 500); + const result = await pool.query( + `SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at + FROM ivanti_finding_bu_history + ORDER BY detected_at DESC + LIMIT $1`, + [maxRows] + ); + rows = result.rows; + } res.json({ changes: rows }); } catch (err) { console.error('[Ivanti Findings] GET /bu-changes error:', err.message); diff --git a/frontend/src/components/pages/AnomalyBanner.js b/frontend/src/components/pages/AnomalyBanner.js index d749dc0..3b3a181 100644 --- a/frontend/src/components/pages/AnomalyBanner.js +++ b/frontend/src/components/pages/AnomalyBanner.js @@ -2,9 +2,11 @@ // Warning banner for the Vulnerability Triage page. // Fetches the latest sync anomaly summary and displays a dismissible // amber banner when a significant count change is detected. +// Clicking the "BU reassignment" row expands a detail view showing +// which specific findings moved and from/to which team. -import React, { useState, useEffect } from 'react'; -import { AlertTriangle, X, ChevronDown, ChevronUp } from 'lucide-react'; +import React, { useState, useEffect, useCallback } from 'react'; +import { AlertTriangle, X, ChevronDown, ChevronUp, ArrowRight, Loader } from 'lucide-react'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; @@ -92,6 +94,69 @@ const DETAIL_COUNT = { color: '#FCD34D', }; +const BU_DETAIL_SECTION = { + marginTop: '0.5rem', + padding: '0.5rem 0.625rem', + background: 'rgba(251, 146, 60, 0.08)', + border: '1px solid rgba(251, 146, 60, 0.15)', + borderRadius: '0.375rem', + maxHeight: '200px', + overflowY: 'auto', +}; + +const BU_DETAIL_ROW = { + display: 'flex', + alignItems: 'center', + gap: '0.4rem', + padding: '0.25rem 0', + fontSize: '0.6rem', + color: '#CBD5E1', + borderBottom: '1px solid rgba(255,255,255,0.04)', +}; + +const BU_DETAIL_HOST = { + fontWeight: '600', + color: '#E2E8F0', + minWidth: '80px', + maxWidth: '140px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}; + +const BU_DETAIL_TEAM = { + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.58rem', + padding: '0.1rem 0.35rem', + borderRadius: '0.2rem', + whiteSpace: 'nowrap', +}; + +const BU_FROM_STYLE = { + ...BU_DETAIL_TEAM, + background: 'rgba(239, 68, 68, 0.15)', + color: '#FCA5A5', +}; + +const BU_TO_STYLE = { + ...BU_DETAIL_TEAM, + background: 'rgba(251, 146, 60, 0.15)', + color: '#FDBA74', +}; + +const BU_ARROW_STYLE = { + width: '10px', + height: '10px', + color: '#64748B', + flexShrink: 0, +}; + +const BU_CLICKABLE_ROW = { + cursor: 'pointer', + borderRadius: '0.2rem', + transition: 'background 0.15s', +}; + // --------------------------------------------------------------------------- // Classification labels for display // --------------------------------------------------------------------------- @@ -128,6 +193,19 @@ function buildSummaryText(anomaly) { return `${count} findings archived — ${breakdown}`; } +// --------------------------------------------------------------------------- +// Shorten BU names for compact display (e.g. NTS-AEO-STEAM → STEAM) +// --------------------------------------------------------------------------- +function shortenBU(bu) { + if (!bu) return '?'; + // Strip common prefixes for compact display + return bu + .replace(/^NTS-AEO-/, '') + .replace(/^SDIT-CSD-ITLS-/, '') + .replace(/^SDIT-CSD-/, '') + .replace(/^NTS-/, ''); +} + // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- @@ -136,6 +214,9 @@ export default function AnomalyBanner() { const [loading, setLoading] = useState(true); const [dismissed, setDismissed] = useState(false); const [expanded, setExpanded] = useState(false); + const [buChanges, setBuChanges] = useState(null); + const [buLoading, setBuLoading] = useState(false); + const [buExpanded, setBuExpanded] = useState(false); useEffect(() => { let cancelled = false; @@ -156,6 +237,35 @@ export default function AnomalyBanner() { return () => { cancelled = true; }; }, []); + // Fetch BU change details when user expands the reassignment section + const loadBuChanges = useCallback(async () => { + if (buChanges !== null) { + // Already loaded — just toggle visibility + setBuExpanded(e => !e); + return; + } + setBuLoading(true); + setBuExpanded(true); + try { + // Scope to changes detected since this anomaly's sync + const since = anomaly?.sync_timestamp || ''; + const url = since + ? `${API_BASE}/ivanti/findings/bu-changes?since=${encodeURIComponent(since)}` + : `${API_BASE}/ivanti/findings/bu-changes?limit=50`; + const res = await fetch(url, { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + setBuChanges(data.changes || []); + } else { + setBuChanges([]); + } + } catch { + setBuChanges([]); + } finally { + setBuLoading(false); + } + }, [anomaly, buChanges]); + // Render nothing while loading, if dismissed, or if anomaly is not significant if (loading || dismissed || !anomaly || !anomaly.is_significant) { return null; @@ -198,6 +308,45 @@ export default function AnomalyBanner() { {Object.entries(CLASSIFICATION_LABELS).map(([key, label]) => { const val = classification[key] || 0; if (val === 0) return null; + // BU reassignment row is clickable to show from/to details + if (key === 'bu_reassignment') { + return ( +
+
+ + {label} + {buLoading && } + {!buLoading && (buExpanded + ? + : + )} + + {val} +
+ {buExpanded && buChanges !== null && buChanges.length > 0 && ( +
+ {buChanges.map((change, idx) => ( +
+ {change.host_name || change.finding_id} + {shortenBU(change.previous_bu)} + + {shortenBU(change.new_bu)} +
+ ))} +
+ )} + {buExpanded && buChanges !== null && buChanges.length === 0 && ( +
+ No detailed BU change records found for this sync +
+ )} +
+ ); + } return (
{label}