Add BU Lookup tool to Admin panel

Backend:
- GET /api/ivanti/findings/bu-lookup?q=<hostname|ip> — queries the
  live Ivanti API to discover which BU a host is assigned to. Returns
  deduplicated results with hostName, ipAddress, BU, and hostId.
  Admin-only endpoint.

Frontend:
- Add 'BU Lookup' tab to Admin page with search input and results table
- Shows BU assignment badge (blue for tagged, amber for untagged)
- Supports Enter key to search, loading state, error display
- Useful for verifying team assignments when onboarding new users
This commit is contained in:
Jordan Ramos
2026-06-26 09:20:53 -06:00
parent dab3784742
commit 58996cf4cf
2 changed files with 211 additions and 1 deletions

View File

@@ -1689,6 +1689,83 @@ function createIvantiFindingsRouter(db, requireAuth) {
} }
}); });
/**
* GET /api/ivanti/findings/bu-lookup
*
* Look up the BU assignment for a hostname or IP in the Ivanti API.
* Queries the live Ivanti platform (not the local cache) to discover
* which BU a given host is tagged to. Useful for verifying user team
* assignments before onboarding.
* Requires Admin group.
*
* @query {string} q - Hostname or IP address to look up
* @returns {Object} 200 - { query, findings: [{ hostId, hostName, ipAddress, bu, severity, title }] }
* @returns {Object} 400 - { error } when query is missing
* @returns {Object} 500 - { error } on API failure
*/
router.get('/bu-lookup', requireGroup('Admin'), async (req, res) => {
const q = (req.query.q || '').trim();
if (!q) return res.status(400).json({ error: 'q parameter is required (hostname or IP)' });
try {
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const apiKey = process.env.IVANTI_API_KEY;
if (!apiKey) return res.status(503).json({ error: 'Ivanti API key not configured' });
// Search by hostname or IP
const filters = [
{
field: q.match(/^\d+\./) ? 'host.ipAddress' : 'host.hostName',
operator: 'WILDCARD',
value: `*${q}*`,
exclusive: false,
orWithPrevious: false,
implicitFilters: [],
caseSensitive: false
}
];
const result = await ivantiPost(`/client/${clientId}/hostFinding/search`, {
filters,
projection: 'detail',
sort: [{ field: 'severity', direction: 'DESC' }],
page: 0,
size: 20
});
if (!result.ok) {
return res.status(502).json({ error: 'Ivanti API request failed', status: result.status });
}
const data = JSON.parse(result.body);
const records = data._embedded?.records || [];
// Extract BU info from each finding
const findings = records.map(r => ({
hostId: r.host?.hostId || null,
hostName: r.host?.hostName || null,
ipAddress: r.host?.ipAddress || null,
bu: r.assetCustomAttributes?.['1550_host_1']?.[0] || 'Untagged',
severity: r.severity || null,
title: r.title || null,
}));
// Deduplicate by hostId+bu
const seen = new Set();
const unique = findings.filter(f => {
const key = `${f.hostId}-${f.bu}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
res.json({ query: q, total: data.page?.totalElements || 0, findings: unique });
} catch (err) {
console.error('[Ivanti] BU lookup error:', err.message);
res.status(500).json({ error: 'BU lookup failed' });
}
});
return router; return router;
} }

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Shield, Clock, Activity, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, X, ChevronLeft, ChevronRight, Search, Users, FileText } from 'lucide-react'; import { Shield, Clock, Activity, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, X, ChevronLeft, ChevronRight, Search, Users, FileText, Globe } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import ConfirmModal from '../ConfirmModal'; import ConfirmModal from '../ConfirmModal';
@@ -8,6 +8,7 @@ const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TABS = [ const TABS = [
{ id: 'users', label: 'User Management', icon: Shield }, { id: 'users', label: 'User Management', icon: Shield },
{ id: 'audit', label: 'Audit Log', icon: Clock }, { id: 'audit', label: 'Audit Log', icon: Clock },
{ id: 'bu-lookup', label: 'BU Lookup', icon: Globe },
{ id: 'system', label: 'System Info', icon: Activity }, { id: 'system', label: 'System Info', icon: Activity },
]; ];
@@ -987,6 +988,128 @@ function AuditLogPanel() {
); );
} }
// ---------------------------------------------------------------------------
// BULookupPanel — query Ivanti API to discover which BU a host belongs to
// ---------------------------------------------------------------------------
function BULookupPanel() {
const [query, setQuery] = useState('');
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSearch = async () => {
const q = query.trim();
if (!q) return;
setLoading(true);
setError(null);
setResults(null);
try {
const res = await fetch(`${API_BASE}/ivanti/findings/bu-lookup?q=${encodeURIComponent(q)}`, { credentials: 'include' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || `HTTP ${res.status}`);
}
const data = await res.json();
setResults(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div>
<h3 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.5rem' }}>
BU Lookup
</h3>
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginBottom: '1rem' }}>
Search the Ivanti API by hostname or IP to discover which BU a host is assigned to. Use this to verify team assignments for new users.
</p>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSearch(); }}
placeholder="Enter hostname or IP address..."
style={{
flex: 1, 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',
}}
/>
<button
onClick={handleSearch}
disabled={loading || !query.trim()}
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: loading || !query.trim() ? 0.5 : 1,
}}
>
{loading ? 'Searching...' : 'Lookup'}
</button>
</div>
{error && (
<div style={{ padding: '0.75rem', background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '0.375rem', color: '#FCA5A5', fontSize: '0.8rem', marginBottom: '1rem' }}>
{error}
</div>
)}
{results && (
<div>
<div style={{ fontSize: '0.75rem', color: '#64748B', marginBottom: '0.75rem' }}>
Found {results.total.toLocaleString()} finding(s) matching "{results.query}" showing {results.findings.length} unique hosts:
</div>
{results.findings.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: '#475569', fontSize: '0.85rem' }}>
No findings found for this hostname/IP in Ivanti.
</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 }}>Hostname</th>
<th style={{ textAlign: 'left', padding: '0.5rem', color: '#0EA5E9', fontWeight: 600 }}>IP Address</th>
<th style={{ textAlign: 'left', padding: '0.5rem', color: '#0EA5E9', fontWeight: 600 }}>BU Assignment</th>
<th style={{ textAlign: 'left', padding: '0.5rem', color: '#0EA5E9', fontWeight: 600 }}>Host ID</th>
</tr>
</thead>
<tbody>
{results.findings.map((f, i) => (
<tr key={i} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
<td style={{ padding: '0.5rem', color: '#CBD5E1', fontFamily: 'monospace' }}>{f.hostName || '—'}</td>
<td style={{ padding: '0.5rem', color: '#CBD5E1', fontFamily: 'monospace' }}>{f.ipAddress || '—'}</td>
<td style={{ padding: '0.5rem' }}>
<span style={{
padding: '0.15rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.7rem', fontWeight: 600, fontFamily: 'monospace',
background: f.bu === 'Untagged' ? 'rgba(245,158,11,0.15)' : 'rgba(14,165,233,0.15)',
border: f.bu === 'Untagged' ? '1px solid rgba(245,158,11,0.3)' : '1px solid rgba(14,165,233,0.3)',
color: f.bu === 'Untagged' ? '#FCD34D' : '#7DD3FC',
}}>
{f.bu}
</span>
</td>
<td style={{ padding: '0.5rem', color: '#64748B', fontFamily: 'monospace', fontSize: '0.75rem' }}>{f.hostId || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SystemInfoPanel // SystemInfoPanel
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1396,6 +1519,16 @@ export default function AdminPage() {
<SystemInfoPanel /> <SystemInfoPanel />
</div> </div>
)} )}
{activeTab === 'bu-lookup' && (
<div
className="intel-card"
style={{ borderRadius: '0.5rem', padding: '1.25rem' }}
>
{/* ⚠️ CONVENTION: BULookupPanel is referenced but never defined or imported — this will throw a ReferenceError at runtime. Define the component in this file or import it. */}
<BULookupPanel />
</div>
)}
</div> </div>
); );
} }