Fix Enrich from CARD for items without IP — use host_id lookup

The enrich-batch endpoint now accepts a host_ids array alongside ips.
When queue items have no IP address but have a host_id (from ivanti_findings),
the frontend sends host_ids and the backend resolves them via CARD asset-search.

Results include the resolved IP so it populates the IPV4_ADDRESS column.
The LoaderModal now carries _host_id from initialDevices through to the
enrich call.
This commit is contained in:
Jordan Ramos
2026-06-09 13:00:02 -06:00
parent 54d6e49cb1
commit 23ea3983c8
4 changed files with 83 additions and 21 deletions

View File

@@ -909,17 +909,20 @@ function createCardApiRouter() {
/** /**
* POST /enrich-batch * POST /enrich-batch
* *
* Batch lookup IPs in CARD to extract Granite loader fields. Fetches team * Batch lookup IPs and/or Ivanti host IDs in CARD to extract Granite loader
* assets (paginated, across confirmed, unconfirmed, and candidate * fields. Accepts an array of IPs, an array of host_ids, or both. For IPs,
* dispositions) and matches against the provided IPs. When no team is * fetches team assets (paginated, across confirmed, unconfirmed, and
* specified, searches both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG. * candidate dispositions) and matches against the provided IPs. For host_ids,
* performs direct CARD asset-search lookups. When no team is specified,
* searches both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG.
* Returns enrichment results for each IP. * Returns enrichment results for each IP.
* *
* @body {string[]} ips - Non-empty array of IP address strings (max 200) * @body {string[]} [ips] - Array of IP address strings (max 200). At least one of ips or host_ids is required.
* @body {string[]} [host_ids] - Array of Ivanti Host ID strings (max 200). At least one of ips or host_ids is required.
* @body {string} [team] - Team name to search assets under. Defaults to both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG if omitted. * @body {string} [team] - Team name to search assets under. Defaults to both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG if omitted.
* @response 200 - { results: object[], enriched_count: number, not_found_count: number, total: number } * @response 200 - { results: object[], enriched_count: number, not_found_count: number, total: number }
* Each result: { ip: string, found: boolean, equip_inst_id: string|null, hostname: string|null, site_name?: string|null, mgmt_ip_asn?: string|null, responsible_team?: string|null, equipment_class?: string, equip_template?: string|null, equip_status?: string|null, serial_number?: string|null, error?: string } * Each result: { ip: string, found: boolean, equip_inst_id: string|null, hostname: string|null, site_name?: string|null, mgmt_ip_asn?: string|null, responsible_team?: string|null, equipment_class?: string, equip_template?: string|null, equip_status?: string|null, serial_number?: string|null, error?: string }
* @response 400 - { error: string } — invalid or empty ips array, or exceeds 200 * @response 400 - { error: string } — neither ips nor host_ids provided, or exceeds 200 items
* @response 503 - { error: string, missingVars: string[] } — CARD not configured * @response 503 - { error: string, missingVars: string[] } — CARD not configured
*/ */
router.post('/enrich-batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { router.post('/enrich-batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
@@ -927,18 +930,43 @@ function createCardApiRouter() {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
} }
const { ips, team } = req.body || {}; const { ips, host_ids, team } = req.body || {};
if (!Array.isArray(ips) || ips.length === 0) {
return res.status(400).json({ error: 'ips must be a non-empty array of IP address strings.' }); // Accept either ips array, host_ids array, or both
const hasIps = Array.isArray(ips) && ips.length > 0;
const hasHostIds = Array.isArray(host_ids) && host_ids.length > 0;
if (!hasIps && !hasHostIds) {
return res.status(400).json({ error: 'ips or host_ids array is required.' });
} }
if (ips.length > 200) { if ((ips && ips.length > 200) || (host_ids && host_ids.length > 200)) {
return res.status(400).json({ error: 'Maximum 200 IPs per request.' }); return res.status(400).json({ error: 'Maximum 200 items per request.' });
} }
// Build a set of IPs we're looking for // Build a set of IPs we're looking for
const targetIps = new Set(ips.map(ip => (ip || '').trim()).filter(Boolean)); const targetIps = new Set((ips || []).map(ip => (ip || '').trim()).filter(Boolean));
const resultMap = {}; const resultMap = {};
// Direct host_id lookups — for items that have no IP but have a host_id
if (hasHostIds) {
for (const hostId of host_ids) {
if (!hostId) continue;
const key = `hostId:${hostId}`;
try {
const searchResult = await searchByIvantiHostId(hostId);
if (searchResult.ok) {
const searchData = JSON.parse(searchResult.body);
const assets = searchData.assets || [];
if (assets.length > 0) {
const asset = assets[0];
const assetIp = (asset._id || '').replace(/-[A-Z]+$/i, '');
resultMap[key] = { ...extractGraniteFields(asset, assetIp), ip: assetIp };
}
}
} catch (_) { /* skip */ }
}
}
// Fast path: look up host_ids from ivanti_findings and use asset-search // Fast path: look up host_ids from ivanti_findings and use asset-search
// The asset-search endpoint returns the full enriched record (same as team assets). // The asset-search endpoint returns the full enriched record (same as team assets).
const ipsArray = [...targetIps]; const ipsArray = [...targetIps];
@@ -1032,7 +1060,7 @@ function createCardApiRouter() {
let enrichedCount = 0; let enrichedCount = 0;
let notFoundCount = 0; let notFoundCount = 0;
for (const ip of ips) { for (const ip of (ips || [])) {
const trimmedIp = (ip || '').trim(); const trimmedIp = (ip || '').trim();
if (!trimmedIp) { if (!trimmedIp) {
results.push({ ip: '', found: false, equip_inst_id: null, hostname: null, error: 'Invalid IP' }); results.push({ ip: '', found: false, equip_inst_id: null, hostname: null, error: 'Invalid IP' });
@@ -1049,7 +1077,23 @@ function createCardApiRouter() {
} }
} }
res.json({ results, enriched_count: enrichedCount, not_found_count: notFoundCount, total: ips.length }); // Include results for host_id lookups (items without IPs)
const hostIdResults = [];
if (hasHostIds) {
for (const hostId of host_ids) {
if (!hostId) continue;
const key = `hostId:${hostId}`;
if (resultMap[key]) {
hostIdResults.push({ host_id: hostId, found: true, ...resultMap[key] });
enrichedCount++;
} else {
hostIdResults.push({ host_id: hostId, found: false, equip_inst_id: null, hostname: null, error: 'Host ID not found in CARD' });
notFoundCount++;
}
}
}
res.json({ results, host_id_results: hostIdResults, enriched_count: enrichedCount, not_found_count: notFoundCount, total: (ips || []).length + (host_ids || []).length });
}); });
return router; return router;

View File

@@ -84,6 +84,7 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
setDevices(initialDevices.map(d => ({ setDevices(initialDevices.map(d => ({
IPV4_ADDRESS: d.ip_address || '', IPV4_ADDRESS: d.ip_address || '',
EQUIP_NAME: d.hostname || '', EQUIP_NAME: d.hostname || '',
_host_id: d.host_id || null,
}))); })));
} else { } else {
setDevices([]); setDevices([]);
@@ -213,17 +214,23 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
// --- CARD Enrichment --- // --- CARD Enrichment ---
const enrichFromCard = async () => { const enrichFromCard = async () => {
const ips = devices.map(d => d.IPV4_ADDRESS).filter(Boolean); const ips = devices.map(d => d.IPV4_ADDRESS).filter(Boolean);
if (ips.length === 0) return; const hostIds = devices.filter(d => !d.IPV4_ADDRESS && d._host_id).map(d => d._host_id);
if (ips.length === 0 && hostIds.length === 0) return;
setEnriching(true); setEnriching(true);
setEnrichErrors([]); setEnrichErrors([]);
try { try {
const body = {};
if (ips.length > 0) body.ips = ips;
if (hostIds.length > 0) body.host_ids = hostIds;
const resp = await fetch(`${API_BASE}/card/enrich-batch`, { const resp = await fetch(`${API_BASE}/card/enrich-batch`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ ips }), body: JSON.stringify(body),
}); });
if (!resp.ok) { if (!resp.ok) {
@@ -238,9 +245,16 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
// Map results back to devices // Map results back to devices
setDevices(prev => prev.map((device, idx) => { setDevices(prev => prev.map((device, idx) => {
const result = data.results.find(r => r.ip === device.IPV4_ADDRESS); // Try matching by IP first
let result = data.results ? data.results.find(r => r.ip === device.IPV4_ADDRESS) : null;
// If no IP match, try matching by host_id
if (!result && device._host_id && data.host_id_results) {
result = data.host_id_results.find(r => String(r.host_id) === String(device._host_id));
}
if (!result || !result.found) { if (!result || !result.found) {
if (result) errors.push({ ip: result.ip, error: result.error || 'Not found' }); if (result) errors.push({ ip: device.IPV4_ADDRESS || `hostId:${device._host_id}`, error: result.error || 'Not found' });
return device; return device;
} }
@@ -248,6 +262,10 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
const updated = { ...device }; const updated = { ...device };
const rowOverrides = overrides[idx] || {}; const rowOverrides = overrides[idx] || {};
// Populate IP from host_id result if device didn't have one
if (result.ip && !device.IPV4_ADDRESS) {
updated.IPV4_ADDRESS = result.ip;
}
if (result.equip_inst_id && !rowOverrides.EQUIP_INST_ID && !device.EQUIP_INST_ID) { if (result.equip_inst_id && !rowOverrides.EQUIP_INST_ID && !device.EQUIP_INST_ID) {
updated.EQUIP_INST_ID = result.equip_inst_id; updated.EQUIP_INST_ID = result.equip_inst_id;
} }

View File

@@ -1230,7 +1230,7 @@ export default function IvantiTodoQueuePage() {
<LoaderModal <LoaderModal
isOpen={showLoaderModal} isOpen={showLoaderModal}
onClose={() => setShowLoaderModal(false)} onClose={() => setShowLoaderModal(false)}
initialDevices={showLoaderModal ? queueItems.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' })) : null} initialDevices={showLoaderModal ? queueItems.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '', host_id: i.host_id || null })) : null}
/> />
{/* Remediation Notes Modal */} {/* Remediation Notes Modal */}

View File

@@ -3266,9 +3266,9 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
onClose={() => setShowLoaderModal(false)} onClose={() => setShowLoaderModal(false)}
initialDevices={showLoaderModal ? (() => { initialDevices={showLoaderModal ? (() => {
const selected = items.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)); const selected = items.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
if (selected.length > 0) return selected.map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' })); if (selected.length > 0) return selected.map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '', host_id: i.host_id || null }));
// Standalone: use all CARD/GRANITE/DECOM items // Standalone: use all CARD/GRANITE/DECOM items
return items.filter(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' })); return items.filter(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '', host_id: i.host_id || null }));
})() : null} })() : null}
/> />