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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 ConfirmModal from '../ConfirmModal';
|
||||
|
||||
@@ -8,6 +8,7 @@ const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TABS = [
|
||||
{ id: 'users', label: 'User Management', icon: Shield },
|
||||
{ id: 'audit', label: 'Audit Log', icon: Clock },
|
||||
{ id: 'bu-lookup', label: 'BU Lookup', icon: Globe },
|
||||
{ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1396,6 +1519,16 @@ export default function AdminPage() {
|
||||
<SystemInfoPanel />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user