Add BU reassignment from/to detail view in anomaly banner

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.
This commit is contained in:
Jordan Ramos
2026-06-12 12:12:59 -06:00
parent 6465ac2a40
commit 356ce23462
2 changed files with 180 additions and 9 deletions

View File

@@ -1433,18 +1433,40 @@ function createIvantiFindingsRouter(db, requireAuth) {
/** /**
* GET /api/ivanti/findings/bu-changes * 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<Object> } * @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 * @returns {Object} 500 - { error: string } on database error
*/ */
router.get('/bu-changes', async (req, res) => { router.get('/bu-changes', async (req, res) => {
try { try {
const { rows } = await pool.query( 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 `SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
FROM ivanti_finding_bu_history FROM ivanti_finding_bu_history
ORDER BY detected_at DESC` 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 }); res.json({ changes: rows });
} catch (err) { } catch (err) {
console.error('[Ivanti Findings] GET /bu-changes error:', err.message); console.error('[Ivanti Findings] GET /bu-changes error:', err.message);

View File

@@ -2,9 +2,11 @@
// Warning banner for the Vulnerability Triage page. // Warning banner for the Vulnerability Triage page.
// Fetches the latest sync anomaly summary and displays a dismissible // Fetches the latest sync anomaly summary and displays a dismissible
// amber banner when a significant count change is detected. // 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 React, { useState, useEffect, useCallback } from 'react';
import { AlertTriangle, X, ChevronDown, ChevronUp } from 'lucide-react'; import { AlertTriangle, X, ChevronDown, ChevronUp, ArrowRight, Loader } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -92,6 +94,69 @@ const DETAIL_COUNT = {
color: '#FCD34D', 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 // Classification labels for display
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -128,6 +193,19 @@ function buildSummaryText(anomaly) {
return `${count} findings archived — ${breakdown}`; 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 // Main component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -136,6 +214,9 @@ export default function AnomalyBanner() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [buChanges, setBuChanges] = useState(null);
const [buLoading, setBuLoading] = useState(false);
const [buExpanded, setBuExpanded] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -156,6 +237,35 @@ export default function AnomalyBanner() {
return () => { cancelled = true; }; 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 // Render nothing while loading, if dismissed, or if anomaly is not significant
if (loading || dismissed || !anomaly || !anomaly.is_significant) { if (loading || dismissed || !anomaly || !anomaly.is_significant) {
return null; return null;
@@ -198,6 +308,45 @@ export default function AnomalyBanner() {
{Object.entries(CLASSIFICATION_LABELS).map(([key, label]) => { {Object.entries(CLASSIFICATION_LABELS).map(([key, label]) => {
const val = classification[key] || 0; const val = classification[key] || 0;
if (val === 0) return null; if (val === 0) return null;
// BU reassignment row is clickable to show from/to details
if (key === 'bu_reassignment') {
return (
<div key={key}>
<div
style={{ ...DETAIL_ROW, ...BU_CLICKABLE_ROW }}
onClick={loadBuChanges}
title="Click to see which findings moved and where"
>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
{label}
{buLoading && <Loader style={{ width: '10px', height: '10px', animation: 'spin 1s linear infinite' }} />}
{!buLoading && (buExpanded
? <ChevronUp style={{ width: '10px', height: '10px', opacity: 0.5 }} />
: <ChevronDown style={{ width: '10px', height: '10px', opacity: 0.5 }} />
)}
</span>
<span style={DETAIL_COUNT}>{val}</span>
</div>
{buExpanded && buChanges !== null && buChanges.length > 0 && (
<div style={BU_DETAIL_SECTION}>
{buChanges.map((change, idx) => (
<div key={change.id || idx} style={BU_DETAIL_ROW} title={change.finding_title || ''}>
<span style={BU_DETAIL_HOST}>{change.host_name || change.finding_id}</span>
<span style={BU_FROM_STYLE}>{shortenBU(change.previous_bu)}</span>
<ArrowRight style={BU_ARROW_STYLE} />
<span style={BU_TO_STYLE}>{shortenBU(change.new_bu)}</span>
</div>
))}
</div>
)}
{buExpanded && buChanges !== null && buChanges.length === 0 && (
<div style={{ ...BU_DETAIL_SECTION, color: '#64748B', fontSize: '0.6rem', textAlign: 'center', padding: '0.5rem' }}>
No detailed BU change records found for this sync
</div>
)}
</div>
);
}
return ( return (
<div key={key} style={DETAIL_ROW}> <div key={key} style={DETAIL_ROW}>
<span>{label}</span> <span>{label}</span>