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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user