Add SVP org hierarchy discovery to BU Lookup panel

Backend:
- GET /api/ivanti/findings/org-hierarchy — returns all SVP names and
  all BU names from Ivanti suggest API (Admin only)
- GET /api/ivanti/findings/bus-by-svp?svp=<name> — returns BUs under
  a specific SVP with finding counts (Admin only)

Frontend (Admin > BU Lookup tab):
- 'Load Org Hierarchy' button fetches all SVPs and BUs
- SVP dropdown (sorted by finding count) to filter BUs by org leader
- Results table shows BU name, finding count, and configured status
  (green badge for BUs already in the dashboard, grey for unconfigured)
- Enables admin to discover which BUs roll up under an SVP for correct
  team assignment when onboarding new users
This commit is contained in:
Jordan Ramos
2026-06-26 09:33:45 -06:00
parent 58996cf4cf
commit 93de7bbca7
2 changed files with 231 additions and 0 deletions

View File

@@ -1766,6 +1766,84 @@ function createIvantiFindingsRouter(db, requireAuth) {
}
});
/**
* GET /api/ivanti/findings/org-hierarchy
*
* Return the full SVP list and BU list from the Ivanti suggest API.
* Used by admin tooling to display the organizational hierarchy for
* BU filter configuration and team assignment verification.
* Requires Admin group.
*
* @returns {Object} 200 - { svps: string[], bus: string[] }
* @returns {Object} 503 - { error: string } when Ivanti API key is not configured
* @returns {Object} 500 - { error: string } on API failure
*/
router.get('/org-hierarchy', requireGroup('Admin'), async (req, res) => {
try {
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const apiKey = process.env.IVANTI_API_KEY;
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
if (!apiKey) return res.status(503).json({ error: 'Ivanti API key not configured' });
// Fetch SVP values
const svpResult = await ivantiPost(`/client/${clientId}/hostFinding/suggest`, {
filter: { field: 'assetCustomAttributes.1550_host_10.value', operator: 'WILDCARD', value: '*', exclusive: false }
}, apiKey, skipTls);
// Fetch all BU values
const buResult = await ivantiPost(`/client/${clientId}/hostFinding/suggest`, {
filter: { field: 'assetCustomAttributes.1550_host_1.value', operator: 'WILDCARD', value: '*', exclusive: false }
}, apiKey, skipTls);
const svps = svpResult.status === 200 ? JSON.parse(svpResult.body) : [];
const bus = buResult.status === 200 ? JSON.parse(buResult.body) : [];
res.json({ svps, bus });
} catch (err) {
console.error('[Ivanti] Org hierarchy error:', err.message);
res.status(500).json({ error: 'Failed to fetch org hierarchy' });
}
});
/**
* GET /api/ivanti/findings/bus-by-svp
*
* Return BU names that belong to a specific SVP, queried from the Ivanti
* suggest API with the SVP as a filter. Used for cascading dropdowns in
* admin configuration UIs.
* Requires Admin group.
*
* @query {string} svp - The SVP name to filter BUs by (required)
*
* @returns {Object} 200 - { svp: string, bus: string[] }
* @returns {Object} 400 - { error: string } when svp query parameter is missing
* @returns {Object} 503 - { error: string } when Ivanti API key is not configured
* @returns {Object} 500 - { error: string } on API failure
*/
router.get('/bus-by-svp', requireGroup('Admin'), async (req, res) => {
const svp = (req.query.svp || '').trim();
if (!svp) return res.status(400).json({ error: 'svp query parameter is required' });
try {
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const apiKey = process.env.IVANTI_API_KEY;
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
if (!apiKey) return res.status(503).json({ error: 'Ivanti API key not configured' });
// Get BUs filtered by the selected SVP
const result = await ivantiPost(`/client/${clientId}/hostFinding/suggest`, {
filter: { field: 'assetCustomAttributes.1550_host_1.value', operator: 'WILDCARD', value: '*', exclusive: false },
filters: [{ field: 'assetCustomAttributes.1550_host_10.value', operator: 'EXACT', value: svp, exclusive: false }]
}, apiKey, skipTls);
const bus = result.status === 200 ? JSON.parse(result.body) : [];
res.json({ svp, bus });
} catch (err) {
console.error('[Ivanti] BUs by SVP error:', err.message);
res.status(500).json({ error: 'Failed to fetch BUs for SVP' });
}
});
return router;
}

View File

@@ -996,6 +996,53 @@ function BULookupPanel() {
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [orgData, setOrgData] = useState(null);
const [orgLoading, setOrgLoading] = useState(false);
const [selectedSvp, setSelectedSvp] = useState('');
const [svpBus, setSvpBus] = useState(null);
const [svpBusLoading, setSvpBusLoading] = useState(false);
const CONFIGURED_BUS = ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG', 'NTS-AEO-ACCESS-OPS', 'NTS-AEO-INTELDEV'];
const handleLoadOrgHierarchy = async () => {
setOrgLoading(true);
setOrgData(null);
setSelectedSvp('');
setSvpBus(null);
try {
const res = await fetch(`${API_BASE}/ivanti/findings/org-hierarchy`, { credentials: 'include' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || `HTTP ${res.status}`);
}
const data = await res.json();
setOrgData(data);
} catch (err) {
setError(err.message);
} finally {
setOrgLoading(false);
}
};
const handleSvpSelect = async (svp) => {
setSelectedSvp(svp);
setSvpBus(null);
if (!svp) return;
setSvpBusLoading(true);
try {
const res = await fetch(`${API_BASE}/ivanti/findings/bus-by-svp?svp=${encodeURIComponent(svp)}`, { credentials: 'include' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || `HTTP ${res.status}`);
}
const data = await res.json();
setSvpBus(data);
} catch (err) {
setError(err.message);
} finally {
setSvpBusLoading(false);
}
};
const handleSearch = async () => {
const q = query.trim();
@@ -1106,6 +1153,112 @@ function BULookupPanel() {
)}
</div>
)}
{/* ----------------------------------------------------------------- */}
{/* Org Discovery — SVP-based BU discovery */}
{/* ----------------------------------------------------------------- */}
<div style={{ marginTop: '2rem', borderTop: '1px solid rgba(14,165,233,0.15)', paddingTop: '1.5rem' }}>
<h3 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.5rem' }}>
Org Discovery
</h3>
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginBottom: '1rem' }}>
Load SVP and BU data from the Ivanti suggest API to discover which teams exist under each SVP. Configured BUs are highlighted in green.
</p>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '1rem' }}>
<button
onClick={handleLoadOrgHierarchy}
disabled={orgLoading}
style={{
padding: '0.5rem 1rem', borderRadius: '0.375rem',
background: 'rgba(14, 165, 233, 0.15)',
border: '1px solid rgba(14, 165, 233, 0.4)',
color: '#0EA5E9', fontSize: '0.8rem', fontWeight: 600,
fontFamily: 'monospace', cursor: 'pointer',
opacity: orgLoading ? 0.5 : 1,
}}
>
{orgLoading ? 'Loading...' : 'Load Org Hierarchy'}
</button>
{orgData && (
<span style={{ fontSize: '0.75rem', color: '#64748B' }}>
{orgData.svps.length} SVPs, {orgData.bus.length} BUs loaded
</span>
)}
</div>
{orgData && orgData.svps.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<select
value={selectedSvp}
onChange={e => handleSvpSelect(e.target.value)}
style={{
width: '100%', padding: '0.5rem 0.75rem',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.375rem', color: '#E2E8F0',
fontSize: '0.85rem', fontFamily: 'monospace', outline: 'none',
}}
>
<option value=""> Select an SVP </option>
{orgData.svps
.sort((a, b) => (b.count || 0) - (a.count || 0))
.map((s, i) => (
<option key={i} value={s.key}>{s.key} ({(s.count || 0).toLocaleString()} findings)</option>
))
}
</select>
</div>
)}
{svpBusLoading && (
<div style={{ padding: '0.75rem', color: '#94A3B8', fontSize: '0.8rem' }}>Loading BUs for selected SVP...</div>
)}
{svpBus && !svpBusLoading && (
<div>
<div style={{ fontSize: '0.75rem', color: '#64748B', marginBottom: '0.75rem' }}>
{svpBus.bus.length} BU(s) under {svpBus.svp}:
</div>
{svpBus.bus.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: '#475569', fontSize: '0.85rem' }}>
No BUs found for this SVP.
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
<th style={{ textAlign: 'left', padding: '0.5rem', color: '#0EA5E9', fontWeight: 600 }}>BU Name</th>
<th style={{ textAlign: 'left', padding: '0.5rem', color: '#0EA5E9', fontWeight: 600 }}>Finding Count</th>
<th style={{ textAlign: 'left', padding: '0.5rem', color: '#0EA5E9', fontWeight: 600 }}>Status</th>
</tr>
</thead>
<tbody>
{svpBus.bus
.sort((a, b) => (b.count || 0) - (a.count || 0))
.map((bu, i) => (
<tr key={i} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
<td style={{ padding: '0.5rem', color: '#CBD5E1', fontFamily: 'monospace' }}>{bu.key}</td>
<td style={{ padding: '0.5rem', color: '#CBD5E1', fontFamily: 'monospace' }}>{(bu.count || 0).toLocaleString()}</td>
<td style={{ padding: '0.5rem' }}>
<span style={{
padding: '0.15rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.7rem', fontWeight: 600, fontFamily: 'monospace',
background: CONFIGURED_BUS.includes(bu.key) ? 'rgba(34,197,94,0.15)' : 'rgba(100,116,139,0.15)',
border: CONFIGURED_BUS.includes(bu.key) ? '1px solid rgba(34,197,94,0.3)' : '1px solid rgba(100,116,139,0.3)',
color: CONFIGURED_BUS.includes(bu.key) ? '#4ADE80' : '#94A3B8',
}}>
{CONFIGURED_BUS.includes(bu.key) ? 'Configured' : 'Not Configured'}
</span>
</td>
</tr>
))
}
</tbody>
</table>
)}
</div>
)}
</div>
</div>
);
}