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
This commit is contained in:
@@ -312,6 +312,23 @@ async function searchByIvantiHostId(ivantiHostId, options) {
|
|||||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
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.
|
* 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.
|
* 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) {
|
async function resolveAssetId(ip, options) {
|
||||||
const quick = options && options.quick;
|
const quick = options && options.quick;
|
||||||
const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
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();
|
const trimmedIp = (ip || '').trim();
|
||||||
if (!trimmedIp) return null;
|
if (!trimmedIp) return null;
|
||||||
|
|
||||||
@@ -384,4 +401,5 @@ module.exports = {
|
|||||||
invalidateToken,
|
invalidateToken,
|
||||||
resolveAssetId,
|
resolveAssetId,
|
||||||
searchByIvantiHostId,
|
searchByIvantiHostId,
|
||||||
|
searchByAssetId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const {
|
|||||||
redirectAsset,
|
redirectAsset,
|
||||||
resolveAssetId,
|
resolveAssetId,
|
||||||
searchByIvantiHostId,
|
searchByIvantiHostId,
|
||||||
|
searchByAssetId,
|
||||||
} = require('../helpers/cardApi');
|
} = 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
|
// 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,
|
// Skip if all unresolved IPs already timed out (heavier calls will also timeout)
|
||||||
// card_flags, netops_granite_allips, etc.
|
const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG', 'NTS-AEO-ACCESS-OPS'];
|
||||||
const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG'];
|
|
||||||
const dispositions = ['confirmed', 'unconfirmed', 'candidate'];
|
const dispositions = ['confirmed', 'unconfirmed', 'candidate'];
|
||||||
|
const stillUnresolved = [...targetIps].filter(ip => !resultMap[ip]);
|
||||||
|
|
||||||
for (const teamName of teams) {
|
for (const teamName of teams) {
|
||||||
if (foundCount >= targetIps.size) break;
|
if (stillUnresolved.length === 0 || foundCount >= targetIps.size) break;
|
||||||
|
|
||||||
for (const disposition of dispositions) {
|
for (const disposition of dispositions) {
|
||||||
if (foundCount >= targetIps.size) break;
|
if (foundCount >= targetIps.size) break;
|
||||||
@@ -1097,8 +1148,13 @@ function createCardApiRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resultMap[trimmedIp]) {
|
if (resultMap[trimmedIp]) {
|
||||||
results.push({ ip: trimmedIp, found: true, ...resultMap[trimmedIp] });
|
if (resultMap[trimmedIp]._timeout) {
|
||||||
enrichedCount++;
|
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 {
|
} else {
|
||||||
results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD' });
|
results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD' });
|
||||||
notFoundCount++;
|
notFoundCount++;
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
|||||||
setBulkDefaults({});
|
setBulkDefaults({});
|
||||||
setEnrichErrors([]);
|
setEnrichErrors([]);
|
||||||
setValidationWarnings([]);
|
setValidationWarnings([]);
|
||||||
|
setEnriching(false);
|
||||||
}, [isOpen, initialDevices]);
|
}, [isOpen, initialDevices]);
|
||||||
|
|
||||||
// Auto-select required columns + useful defaults when operation type changes
|
// Auto-select required columns + useful defaults when operation type changes
|
||||||
@@ -417,11 +418,27 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
|||||||
{/* Enrich errors */}
|
{/* Enrich errors */}
|
||||||
{enrichErrors.length > 0 && (
|
{enrichErrors.length > 0 && (
|
||||||
<div style={{ marginBottom: '0.75rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem' }}>
|
<div style={{ marginBottom: '0.75rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem' }}>
|
||||||
<div style={{ fontSize: '0.7rem', color: '#EF4444', display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
<div style={{ fontSize: '0.7rem', color: '#EF4444', display: 'flex', alignItems: 'center', gap: '0.3rem', justifyContent: 'space-between' }}>
|
||||||
<AlertCircle style={{ width: '12px', height: '12px' }} />
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
||||||
{enrichErrors.length === 1 && enrichErrors[0].ip === 'all'
|
<AlertCircle style={{ width: '12px', height: '12px' }} />
|
||||||
? enrichErrors[0].error
|
{enrichErrors.length === 1 && enrichErrors[0].ip === 'all'
|
||||||
: `${enrichErrors.length} device(s) not found in CARD`}
|
? 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`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={enrichFromCard}
|
||||||
|
disabled={enriching}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(239, 68, 68, 0.15)', border: '1px solid rgba(239, 68, 68, 0.4)',
|
||||||
|
borderRadius: '0.25rem', padding: '0.2rem 0.5rem',
|
||||||
|
color: '#F87171', cursor: 'pointer',
|
||||||
|
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '600',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
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 { useAuth } from '../../contexts/AuthContext';
|
||||||
import ComplianceUploadModal from './ComplianceUploadModal';
|
import ComplianceUploadModal from './ComplianceUploadModal';
|
||||||
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
||||||
import ComplianceChartsPanel from './ComplianceChartsPanel';
|
import ComplianceChartsPanel from './ComplianceChartsPanel';
|
||||||
import MetricInfoPanel from './MetricInfoPanel';
|
import MetricInfoPanel from './MetricInfoPanel';
|
||||||
import VCLReportPage from './VCLReportPage';
|
import VCLReportPage from './VCLReportPage';
|
||||||
|
import LoaderModal from '../LoaderModal';
|
||||||
import metricDefinitionsRaw from '../../data/metricDefinitions.json';
|
import metricDefinitionsRaw from '../../data/metricDefinitions.json';
|
||||||
import metricCategoriesConfig from '../../data/complianceCategories.json';
|
import metricCategoriesConfig from '../../data/complianceCategories.json';
|
||||||
|
|
||||||
@@ -361,6 +362,10 @@ export default function CompliancePage({ onNavigate }) {
|
|||||||
const [rollbackResult, setRollbackResult] = useState(null);
|
const [rollbackResult, setRollbackResult] = useState(null);
|
||||||
const [infoMetric, setInfoMetric] = useState(null);
|
const [infoMetric, setInfoMetric] = useState(null);
|
||||||
const [hoveredMetric, setHoveredMetric] = 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 hoverTimeoutRef = useRef(null);
|
||||||
const hoveredCardRef = useRef(null);
|
const hoveredCardRef = useRef(null);
|
||||||
|
|
||||||
@@ -392,6 +397,8 @@ export default function CompliancePage({ onNavigate }) {
|
|||||||
setFilterState(null);
|
setFilterState(null);
|
||||||
setHostSearch('');
|
setHostSearch('');
|
||||||
setSelectedHost(null);
|
setSelectedHost(null);
|
||||||
|
setSelectedDevices(new Set());
|
||||||
|
setCurrentPage(1);
|
||||||
fetchSummary(activeTeam);
|
fetchSummary(activeTeam);
|
||||||
fetchDevices(activeTeam, activeTab);
|
fetchDevices(activeTeam, activeTab);
|
||||||
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -406,14 +413,50 @@ export default function CompliancePage({ onNavigate }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFilterState(null);
|
setFilterState(null);
|
||||||
|
setSelectedDevices(new Set());
|
||||||
|
setCurrentPage(1);
|
||||||
fetchDevices(activeTeam, activeTab);
|
fetchDevices(activeTeam, activeTab);
|
||||||
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Reset page when filter changes
|
||||||
|
useEffect(() => { setCurrentPage(1); }, [filterState]);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
fetchSummary(activeTeam);
|
fetchSummary(activeTeam);
|
||||||
fetchDevices(activeTeam, activeTab);
|
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 () => {
|
const handleRollback = async () => {
|
||||||
if (!lastUpload) return;
|
if (!lastUpload) return;
|
||||||
setRollbackLoading(true);
|
setRollbackLoading(true);
|
||||||
@@ -446,6 +489,11 @@ export default function CompliancePage({ onNavigate }) {
|
|||||||
})
|
})
|
||||||
.filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
|
.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 families = groupByMetricFamily(summary.entries, activeTeam);
|
||||||
const lastUpload = summary.upload;
|
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)',
|
padding: '0.875rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
}}>
|
}}>
|
||||||
{/* Active / Resolved tabs */}
|
{/* Active / Resolved tabs */}
|
||||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', gap: '0.25rem', alignItems: 'center' }}>
|
||||||
{['active', 'resolved'].map(tab => {
|
{['active', 'resolved'].map(tab => {
|
||||||
const isActive = activeTab === tab;
|
const isActive = activeTab === tab;
|
||||||
return (
|
return (
|
||||||
@@ -747,12 +795,36 @@ export default function CompliancePage({ onNavigate }) {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{selectedDevices.size > 0 && canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={openGraniteLoader}
|
||||||
|
title="Generate Granite Loader Sheet from selected devices"
|
||||||
|
style={{
|
||||||
|
marginLeft: '0.75rem',
|
||||||
|
padding: '0.35rem 0.75rem',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.35rem',
|
||||||
|
background: 'rgba(124, 58, 237, 0.12)',
|
||||||
|
border: '1px solid rgba(124, 58, 237, 0.5)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
color: '#A78BFA',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.2)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.8)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.12)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.5)'; }}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet style={{ width: '13px', height: '13px' }} />
|
||||||
|
Granite ({selectedDevices.size})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hostname search */}
|
{/* Hostname search */}
|
||||||
<input
|
<input
|
||||||
value={hostSearch}
|
value={hostSearch}
|
||||||
onChange={e => setHostSearch(e.target.value)}
|
onChange={e => { setHostSearch(e.target.value); setCurrentPage(1); }}
|
||||||
placeholder="Search hostname…"
|
placeholder="Search hostname…"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
|
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 */}
|
{/* Column headers */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
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.5rem 1rem',
|
padding: '0.5rem 1rem',
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
fontSize: '0.62rem', color: '#334155',
|
fontSize: '0.62rem', color: '#334155',
|
||||||
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em',
|
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||||
|
alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
|
<span style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={paginatedDevices.length > 0 && paginatedDevices.every(d => selectedDevices.has(d.hostname))}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
style={{ cursor: 'pointer', accentColor: TEAL }}
|
||||||
|
title="Select all on this page"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<span>Hostname</span>
|
<span>Hostname</span>
|
||||||
<span>IP Address</span>
|
<span>IP Address</span>
|
||||||
<span>Type</span>
|
<span>Type</span>
|
||||||
@@ -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'}
|
{lastUpload === null ? 'No reports uploaded yet' : filterState ? 'No devices match the selected filter' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredDevices.map(device => (
|
paginatedDevices.map(device => (
|
||||||
<DeviceRow
|
<DeviceRow
|
||||||
key={device.hostname}
|
key={device.hostname}
|
||||||
device={device}
|
device={device}
|
||||||
selected={selectedHost === device.hostname}
|
selected={selectedHost === device.hostname}
|
||||||
|
checked={selectedDevices.has(device.hostname)}
|
||||||
|
onCheck={() => toggleDeviceSelection(device.hostname)}
|
||||||
onClick={() => setSelectedHost(selectedHost === device.hostname ? null : device.hostname)}
|
onClick={() => setSelectedHost(selectedHost === device.hostname ? null : device.hostname)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pagination controls */}
|
||||||
|
{filteredDevices.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '0.75rem 1rem', borderTop: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||||
|
Showing {((safePage - 1) * pageSize) + 1}–{Math.min(safePage * pageSize, filteredDevices.length)} of {filteredDevices.length}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={e => { setPageSize(Number(e.target.value)); setCurrentPage(1); }}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
|
||||||
|
borderRadius: '0.25rem', color: '#94A3B8', fontSize: '0.68rem',
|
||||||
|
fontFamily: 'monospace', padding: '0.2rem 0.4rem', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
<option value={200}>200</option>
|
||||||
|
</select>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>per page</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={safePage <= 1}
|
||||||
|
style={{
|
||||||
|
padding: '0.3rem 0.6rem', borderRadius: '0.25rem',
|
||||||
|
border: '1px solid rgba(20,184,166,0.2)', background: 'transparent',
|
||||||
|
color: safePage <= 1 ? '#1E293B' : '#94A3B8', cursor: safePage <= 1 ? 'default' : 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#64748B', fontFamily: 'monospace', padding: '0 0.5rem' }}>
|
||||||
|
{safePage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={safePage >= totalPages}
|
||||||
|
style={{
|
||||||
|
padding: '0.3rem 0.6rem', borderRadius: '0.25rem',
|
||||||
|
border: '1px solid rgba(20,184,166,0.2)', background: 'transparent',
|
||||||
|
color: safePage >= totalPages ? '#1E293B' : '#94A3B8', cursor: safePage >= totalPages ? 'default' : 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{/* ── Detail panel ─────────────────────────────────────────── */}
|
{/* ── Detail panel ─────────────────────────────────────────── */}
|
||||||
@@ -949,11 +1091,18 @@ export default function CompliancePage({ onNavigate }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Granite Loader Modal ─────────────────────────────────── */}
|
||||||
|
<LoaderModal
|
||||||
|
isOpen={showLoaderModal}
|
||||||
|
onClose={() => setShowLoaderModal(false)}
|
||||||
|
initialDevices={showLoaderModal ? getLoaderDevices() : null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeviceRow({ device, selected, onClick }) {
|
function DeviceRow({ device, selected, checked, onCheck, onClick }) {
|
||||||
const truncateText = (text, maxLen = 80) => {
|
const truncateText = (text, maxLen = 80) => {
|
||||||
if (!text) return '—';
|
if (!text) return '—';
|
||||||
return text.length > maxLen ? text.slice(0, maxLen) + '…' : text;
|
return text.length > maxLen ? text.slice(0, maxLen) + '…' : text;
|
||||||
@@ -964,7 +1113,7 @@ function DeviceRow({ device, selected, onClick }) {
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
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',
|
padding: '0.625rem 1rem',
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||||
cursor: 'pointer',
|
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)'; }}
|
onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.025)'; }}
|
||||||
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
||||||
>
|
>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onCheck}
|
||||||
|
style={{ cursor: 'pointer', accentColor: TEAL }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/* Hostname */}
|
{/* Hostname */}
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: '0.78rem', color: selected ? TEAL : '#E2E8F0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<div style={{ fontFamily: 'monospace', fontSize: '0.78rem', color: selected ? TEAL : '#E2E8F0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{device.hostname}
|
{device.hostname}
|
||||||
|
|||||||
Reference in New Issue
Block a user