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:
Jordan Ramos
2026-06-19 13:49:26 -06:00
parent c7274be66d
commit e9d6038636
4 changed files with 269 additions and 20 deletions

View File

@@ -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,
}; };

View File

@@ -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++;

View File

@@ -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>
)} )}

View File

@@ -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}