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:
@@ -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;
|
||||||
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
|
let rows;
|
||||||
FROM ivanti_finding_bu_history
|
if (since) {
|
||||||
ORDER BY detected_at DESC`
|
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 });
|
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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user