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}