From e9d6038636644b5bfcea27844df81c92209a48d4 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Fri, 19 Jun 2026 13:49:26 -0600 Subject: [PATCH] Add Granite Loader to AEO Compliance page with CARD enrichment and pagination - Add checkbox selection + Granite Loader button to compliance device table - Integrate LoaderModal for generating loader sheets from compliance devices - Add direct IP resolve path (resolveAssetId + searchByAssetId) for CARD enrichment on compliance devices without Ivanti host IDs - Add searchByAssetId helper for full enriched record via asset-search endpoint - Include NTS-AEO-ACCESS-OPS in default enrich-batch team search - Increase CARD quick-mode timeout from 15s to 30s - Add timeout vs not-found distinction in enrichment error reporting - Fix LoaderModal enriching state not resetting on modal reopen - Add pagination to compliance device table (25/50/100/200 per page) - Page resets on team, tab, filter, or search change --- backend/helpers/cardApi.js | 20 +- backend/routes/cardApi.js | 70 ++++++- frontend/src/components/LoaderModal.js | 27 ++- .../src/components/pages/CompliancePage.js | 172 +++++++++++++++++- 4 files changed, 269 insertions(+), 20 deletions(-) diff --git a/backend/helpers/cardApi.js b/backend/helpers/cardApi.js index 815e7b2..b93b211 100644 --- a/backend/helpers/cardApi.js +++ b/backend/helpers/cardApi.js @@ -312,6 +312,23 @@ async function searchByIvantiHostId(ivantiHostId, options) { return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 }; } +/** + * GET /api/v2/asset-search/{assetId}?search_param=deep_search + * Search CARD by asset ID (e.g., "24.24.100.20-CTEC"). Returns the full + * enriched asset record including ncim_discovery, netops_granite_allips, etc. + * + * @param {string} assetId - CARD asset identifier (IP-SUFFIX format) + * @param {object} [options] - { timeout } + */ +async function searchByAssetId(assetId, options) { + const id = (assetId || '').trim(); + if (!id) { + return { status: 400, body: '{"error":"Asset ID is required."}', ok: false }; + } + const res = await cardGet(`/api/v2/asset-search/${encodeURIComponent(id)}?search_param=deep_search`, options); + return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 }; +} + /** * Resolve a bare IP address to a full CARD asset ID by trying known suffixes. * Returns the first asset ID that returns a valid owner record, or null if none found. @@ -322,7 +339,7 @@ async function searchByIvantiHostId(ivantiHostId, options) { async function resolveAssetId(ip, options) { const quick = options && options.quick; const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP']; - const timeout = quick ? 15000 : undefined; // 15s timeout for quick mode + const timeout = quick ? 30000 : undefined; // 30s timeout for quick mode const trimmedIp = (ip || '').trim(); if (!trimmedIp) return null; @@ -384,4 +401,5 @@ module.exports = { invalidateToken, resolveAssetId, searchByIvantiHostId, + searchByAssetId, }; diff --git a/backend/routes/cardApi.js b/backend/routes/cardApi.js index c572022..53ba753 100644 --- a/backend/routes/cardApi.js +++ b/backend/routes/cardApi.js @@ -17,6 +17,7 @@ const { redirectAsset, resolveAssetId, searchByIvantiHostId, + searchByAssetId, } = require('../helpers/cardApi'); // --------------------------------------------------------------------------- @@ -1025,16 +1026,66 @@ function createCardApiRouter() { } } - let foundCount = Object.keys(resultMap).length; + // Direct resolve path: for IPs not found via ivanti_findings, resolve + // the asset ID via suffix guessing (CTEC first) and fetch the full asset + // record via asset-search. This returns ncim_discovery, netops_granite, etc. + const unresolvedIps = ipsArray.filter(ip => !resultMap[ip]); + if (unresolvedIps.length > 0) { + const CONCURRENCY = 5; + for (let i = 0; i < unresolvedIps.length; i += CONCURRENCY) { + const batch = unresolvedIps.slice(i, i + CONCURRENCY); + await Promise.all(batch.map(async (ip) => { + if (resultMap[ip]) return; + try { + const assetId = await resolveAssetId(ip, { quick: true }); + if (assetId) { + // Use asset-search to get the full enriched record (30s timeout) + const searchResult = await searchByAssetId(assetId, { timeout: 30000 }); + if (searchResult.ok) { + const searchData = JSON.parse(searchResult.body); + const assets = searchData.assets || []; + if (assets.length > 0) { + resultMap[ip] = extractGraniteFields(assets[0], ip); + } else { + // Fallback: asset-search returned empty, try owner record + const ownerResult = await getOwner(assetId); + if (ownerResult.ok) { + const ownerData = JSON.parse(ownerResult.body); + resultMap[ip] = extractGraniteFields(ownerData, ip); + } + } + } else { + // asset-search failed, fall back to owner endpoint + const ownerResult = await getOwner(assetId); + if (ownerResult.ok) { + const ownerData = JSON.parse(ownerResult.body); + resultMap[ip] = extractGraniteFields(ownerData, ip); + } + } + } + } catch (err) { + const isTimeout = err.message && (err.message.includes('CARD_TIMEOUT') || err.message.includes('timed out')); + if (isTimeout) { + console.warn(`[card-api] enrich-batch: Direct resolve for ${ip} timed out`); + resultMap[ip] = { _timeout: true }; + } else { + console.warn(`[card-api] enrich-batch: Direct resolve for ${ip} failed:`, err.message); + } + } + })); + } + } + + let foundCount = Object.keys(resultMap).filter(k => !resultMap[k]._timeout).length; // Fallback: paginated team-assets loop for any IPs not resolved by fast path - // The team assets endpoint returns the full enriched record with ncim_discovery, - // card_flags, netops_granite_allips, etc. - const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG']; + // Skip if all unresolved IPs already timed out (heavier calls will also timeout) + const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG', 'NTS-AEO-ACCESS-OPS']; const dispositions = ['confirmed', 'unconfirmed', 'candidate']; + const stillUnresolved = [...targetIps].filter(ip => !resultMap[ip]); for (const teamName of teams) { - if (foundCount >= targetIps.size) break; + if (stillUnresolved.length === 0 || foundCount >= targetIps.size) break; for (const disposition of dispositions) { if (foundCount >= targetIps.size) break; @@ -1097,8 +1148,13 @@ function createCardApiRouter() { } if (resultMap[trimmedIp]) { - results.push({ ip: trimmedIp, found: true, ...resultMap[trimmedIp] }); - enrichedCount++; + if (resultMap[trimmedIp]._timeout) { + results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'CARD lookup timed out — try again' }); + notFoundCount++; + } else { + results.push({ ip: trimmedIp, found: true, ...resultMap[trimmedIp] }); + enrichedCount++; + } } else { results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD' }); notFoundCount++; diff --git a/frontend/src/components/LoaderModal.js b/frontend/src/components/LoaderModal.js index 867694e..2b46be5 100644 --- a/frontend/src/components/LoaderModal.js +++ b/frontend/src/components/LoaderModal.js @@ -95,6 +95,7 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) { setBulkDefaults({}); setEnrichErrors([]); setValidationWarnings([]); + setEnriching(false); }, [isOpen, initialDevices]); // Auto-select required columns + useful defaults when operation type changes @@ -417,11 +418,27 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) { {/* Enrich errors */} {enrichErrors.length > 0 && (
-
- - {enrichErrors.length === 1 && enrichErrors[0].ip === 'all' - ? enrichErrors[0].error - : `${enrichErrors.length} device(s) not found in CARD`} +
+ + + {enrichErrors.length === 1 && enrichErrors[0].ip === 'all' + ? enrichErrors[0].error + : enrichErrors.some(e => e.error && e.error.includes('timed out')) + ? `${enrichErrors.length} device(s) timed out — CARD may be slow` + : `${enrichErrors.length} device(s) not found in CARD`} + +
)} diff --git a/frontend/src/components/pages/CompliancePage.js b/frontend/src/components/pages/CompliancePage.js index df27581..2528b38 100644 --- a/frontend/src/components/pages/CompliancePage.js +++ b/frontend/src/components/pages/CompliancePage.js @@ -1,11 +1,12 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw, Info } from 'lucide-react'; +import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw, Info, FileSpreadsheet } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; import ComplianceUploadModal from './ComplianceUploadModal'; import ComplianceDetailPanel from './ComplianceDetailPanel'; import ComplianceChartsPanel from './ComplianceChartsPanel'; import MetricInfoPanel from './MetricInfoPanel'; import VCLReportPage from './VCLReportPage'; +import LoaderModal from '../LoaderModal'; import metricDefinitionsRaw from '../../data/metricDefinitions.json'; import metricCategoriesConfig from '../../data/complianceCategories.json'; @@ -361,6 +362,10 @@ export default function CompliancePage({ onNavigate }) { const [rollbackResult, setRollbackResult] = useState(null); const [infoMetric, setInfoMetric] = useState(null); const [hoveredMetric, setHoveredMetric] = useState(null); + const [selectedDevices, setSelectedDevices] = useState(new Set()); + const [showLoaderModal, setShowLoaderModal] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); const hoverTimeoutRef = useRef(null); const hoveredCardRef = useRef(null); @@ -392,6 +397,8 @@ export default function CompliancePage({ onNavigate }) { setFilterState(null); setHostSearch(''); setSelectedHost(null); + setSelectedDevices(new Set()); + setCurrentPage(1); fetchSummary(activeTeam); fetchDevices(activeTeam, activeTab); }, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps @@ -406,14 +413,50 @@ export default function CompliancePage({ onNavigate }) { useEffect(() => { setFilterState(null); + setSelectedDevices(new Set()); + setCurrentPage(1); fetchDevices(activeTeam, activeTab); }, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps + // Reset page when filter changes + useEffect(() => { setCurrentPage(1); }, [filterState]); + const refresh = () => { fetchSummary(activeTeam); fetchDevices(activeTeam, activeTab); }; + const toggleDeviceSelection = (hostname) => { + setSelectedDevices(prev => { + const next = new Set(prev); + if (next.has(hostname)) next.delete(hostname); + else next.add(hostname); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selectedDevices.size === paginatedDevices.length && paginatedDevices.every(d => selectedDevices.has(d.hostname))) { + setSelectedDevices(new Set()); + } else { + setSelectedDevices(new Set(paginatedDevices.map(d => d.hostname))); + } + }; + + const openGraniteLoader = () => { + setShowLoaderModal(true); + }; + + const getLoaderDevices = () => { + return filteredDevices + .filter(d => selectedDevices.has(d.hostname)) + .map(d => ({ + ip_address: d.ip_address || '', + hostname: d.hostname || '', + host_id: null, + })); + }; + const handleRollback = async () => { if (!lastUpload) return; setRollbackLoading(true); @@ -446,6 +489,11 @@ export default function CompliancePage({ onNavigate }) { }) .filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase())); + // Pagination + const totalPages = Math.max(1, Math.ceil(filteredDevices.length / pageSize)); + const safePage = Math.min(currentPage, totalPages); + const paginatedDevices = filteredDevices.slice((safePage - 1) * pageSize, safePage * pageSize); + const families = groupByMetricFamily(summary.entries, activeTeam); const lastUpload = summary.upload; @@ -724,7 +772,7 @@ export default function CompliancePage({ onNavigate }) { padding: '0.875rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.05)', }}> {/* Active / Resolved tabs */} -
+
{['active', 'resolved'].map(tab => { const isActive = activeTab === tab; return ( @@ -747,12 +795,36 @@ export default function CompliancePage({ onNavigate }) { ); })} + {selectedDevices.size > 0 && canWrite() && ( + + )}
{/* Hostname search */} setHostSearch(e.target.value)} + onChange={e => { setHostSearch(e.target.value); setCurrentPage(1); }} placeholder="Search hostname…" style={{ background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)', @@ -768,12 +840,22 @@ export default function CompliancePage({ onNavigate }) { {/* Column headers */}
+ + 0 && paginatedDevices.every(d => selectedDevices.has(d.hostname))} + onChange={toggleSelectAll} + style={{ cursor: 'pointer', accentColor: TEAL }} + title="Select all on this page" + /> + Hostname IP Address Type @@ -798,15 +880,75 @@ export default function CompliancePage({ onNavigate }) { {lastUpload === null ? 'No reports uploaded yet' : filterState ? 'No devices match the selected filter' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
) : ( - filteredDevices.map(device => ( + paginatedDevices.map(device => ( toggleDeviceSelection(device.hostname)} onClick={() => setSelectedHost(selectedHost === device.hostname ? null : device.hostname)} /> )) )} + + {/* Pagination controls */} + {filteredDevices.length > 0 && ( +
+
+ + Showing {((safePage - 1) * pageSize) + 1}–{Math.min(safePage * pageSize, filteredDevices.length)} of {filteredDevices.length} + + + per page +
+
+ + + {safePage} / {totalPages} + + +
+
+ )}
} {/* ── Detail panel ─────────────────────────────────────────── */} @@ -949,11 +1091,18 @@ export default function CompliancePage({ onNavigate }) { )}
)} + + {/* ── Granite Loader Modal ─────────────────────────────────── */} + setShowLoaderModal(false)} + initialDevices={showLoaderModal ? getLoaderDevices() : null} + /> ); } -function DeviceRow({ device, selected, onClick }) { +function DeviceRow({ device, selected, checked, onCheck, onClick }) { const truncateText = (text, maxLen = 80) => { if (!text) return '—'; return text.length > maxLen ? text.slice(0, maxLen) + '…' : text; @@ -964,7 +1113,7 @@ function DeviceRow({ device, selected, onClick }) { onClick={onClick} style={{ display: 'grid', - gridTemplateColumns: '2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr', + gridTemplateColumns: '0.3fr 2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr', padding: '0.625rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.04)', cursor: 'pointer', @@ -976,6 +1125,15 @@ function DeviceRow({ device, selected, onClick }) { onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.025)'; }} onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }} > + {/* Checkbox */} +
e.stopPropagation()}> + +
{/* Hostname */}
{device.hostname}