From 23ea3983c8fdb5422119bb3140f3984886ce7819 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Tue, 9 Jun 2026 13:00:02 -0600 Subject: [PATCH] =?UTF-8?q?Fix=20Enrich=20from=20CARD=20for=20items=20with?= =?UTF-8?q?out=20IP=20=E2=80=94=20use=20host=5Fid=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/routes/cardApi.js | 72 +++++++++++++++---- frontend/src/components/LoaderModal.js | 26 +++++-- .../components/pages/IvantiTodoQueuePage.js | 2 +- .../src/components/pages/ReportingPage.js | 4 +- 4 files changed, 83 insertions(+), 21 deletions(-) diff --git a/backend/routes/cardApi.js b/backend/routes/cardApi.js index 63151db..ac5d2ab 100644 --- a/backend/routes/cardApi.js +++ b/backend/routes/cardApi.js @@ -909,17 +909,20 @@ function createCardApiRouter() { /** * POST /enrich-batch * - * Batch lookup IPs in CARD to extract Granite loader fields. Fetches team - * assets (paginated, across confirmed, unconfirmed, and candidate - * dispositions) and matches against the provided IPs. When no team is - * specified, searches both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG. + * Batch lookup IPs and/or Ivanti host IDs in CARD to extract Granite loader + * fields. Accepts an array of IPs, an array of host_ids, or both. For IPs, + * fetches team assets (paginated, across confirmed, unconfirmed, and + * 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. * - * @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. * @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 } - * @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 */ 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 }); } - const { ips, 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.' }); + const { ips, host_ids, team } = req.body || {}; + + // 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) { - return res.status(400).json({ error: 'Maximum 200 IPs per request.' }); + if ((ips && ips.length > 200) || (host_ids && host_ids.length > 200)) { + return res.status(400).json({ error: 'Maximum 200 items per request.' }); } // 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 = {}; + // 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 // The asset-search endpoint returns the full enriched record (same as team assets). const ipsArray = [...targetIps]; @@ -1032,7 +1060,7 @@ function createCardApiRouter() { let enrichedCount = 0; let notFoundCount = 0; - for (const ip of ips) { + for (const ip of (ips || [])) { const trimmedIp = (ip || '').trim(); if (!trimmedIp) { 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; diff --git a/frontend/src/components/LoaderModal.js b/frontend/src/components/LoaderModal.js index cbe23ca..2f8eb2c 100644 --- a/frontend/src/components/LoaderModal.js +++ b/frontend/src/components/LoaderModal.js @@ -84,6 +84,7 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) { setDevices(initialDevices.map(d => ({ IPV4_ADDRESS: d.ip_address || '', EQUIP_NAME: d.hostname || '', + _host_id: d.host_id || null, }))); } else { setDevices([]); @@ -213,17 +214,23 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) { // --- CARD Enrichment --- const enrichFromCard = async () => { 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); setEnrichErrors([]); 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`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ ips }), + body: JSON.stringify(body), }); if (!resp.ok) { @@ -238,9 +245,16 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) { // Map results back to devices 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) 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; } @@ -248,6 +262,10 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) { const updated = { ...device }; 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) { updated.EQUIP_INST_ID = result.equip_inst_id; } diff --git a/frontend/src/components/pages/IvantiTodoQueuePage.js b/frontend/src/components/pages/IvantiTodoQueuePage.js index 5a8ac05..5805795 100644 --- a/frontend/src/components/pages/IvantiTodoQueuePage.js +++ b/frontend/src/components/pages/IvantiTodoQueuePage.js @@ -1230,7 +1230,7 @@ export default function IvantiTodoQueuePage() { 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 */} diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 42b56e8..60588d2 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -3266,9 +3266,9 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on onClose={() => setShowLoaderModal(false)} initialDevices={showLoaderModal ? (() => { 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 - 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} />