- Infoblox WAPI integration (read-only) for resolving IPs to FQDNs - backend/helpers/infobloxApi.js — Basic auth, host/PTR/IPv6 lookups - backend/routes/infoblox.js — /api/infoblox endpoints - Globe icon on Reporting page hostName/dns columns for one-click DNS lookup - Pending: WAPI credentials with API access permissions - Scan Posture page for Access Ops platform/version tracking - backend/routes/scanPosture.js - frontend/src/components/pages/ScanPosturePage.js - Page visibility and nav drawer entries - Ivanti findings OS field enrichment - Migration to add os_name/os_class/os_version columns - Backfill script for existing findings - ivantiFindings route updates to persist OS data on sync
470 lines
22 KiB
JavaScript
470 lines
22 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { Monitor, Shield, AlertTriangle, Clock, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Server, Wifi, X } from 'lucide-react';
|
|
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Color helpers
|
|
// ---------------------------------------------------------------------------
|
|
const SEVERITY_COLORS = {
|
|
critical: '#EF4444',
|
|
high: '#F59E0B',
|
|
medium: '#3B82F6',
|
|
low: '#10B981',
|
|
};
|
|
|
|
function vendorColor(vendor) {
|
|
const v = (vendor || '').toUpperCase();
|
|
if (v.includes('CISCO')) return '#0EA5E9';
|
|
if (v.includes('JUNIPER')) return '#10B981';
|
|
if (v.includes('ARISTA')) return '#8B5CF6';
|
|
if (v.includes('NOKIA')) return '#F97316';
|
|
if (v.includes('RED HAT')) return '#EF4444';
|
|
if (v.includes('LINUX')) return '#F97316';
|
|
if (v.includes('ROCKY')) return '#14B8A6';
|
|
if (v.includes('ALMA')) return '#6366F1';
|
|
if (v.includes('MICROSOFT')) return '#3B82F6';
|
|
if (v.includes('DELL')) return '#A78BFA';
|
|
if (v.includes('ADVA')) return '#EC4899';
|
|
if (v.includes('VECIMA')) return '#84CC16';
|
|
if (v.includes('ADTRAN')) return '#F472B6';
|
|
return '#64748B';
|
|
}
|
|
|
|
function scanTypeLabel(type) {
|
|
if (type === 'agent') return 'Agent';
|
|
if (type === 'network') return 'Network';
|
|
return 'Mixed';
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return 'N/A';
|
|
const d = new Date(dateStr);
|
|
if (isNaN(d.getTime())) return dateStr;
|
|
const now = new Date();
|
|
const diffMs = now - d;
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
if (diffDays === 0) return 'Today';
|
|
if (diffDays === 1) return 'Yesterday';
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
}
|
|
|
|
function formatModel(model) {
|
|
if (!model || model === 'UNSPECIFIED') return null;
|
|
return model;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main Page Component
|
|
// ---------------------------------------------------------------------------
|
|
export default function ScanPosturePage() {
|
|
const [platforms, setPlatforms] = useState([]);
|
|
const [summary, setSummary] = useState(null);
|
|
const [expandedTile, setExpandedTile] = useState(null);
|
|
const [tileDetails, setTileDetails] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const [platRes, sumRes] = await Promise.all([
|
|
fetch(`${API_BASE}/scan-posture/platforms`, { credentials: 'include' }),
|
|
fetch(`${API_BASE}/scan-posture/summary`, { credentials: 'include' }),
|
|
]);
|
|
if (!platRes.ok) throw new Error(`Platforms: ${platRes.status}`);
|
|
if (!sumRes.ok) throw new Error(`Summary: ${sumRes.status}`);
|
|
const platData = await platRes.json();
|
|
const sumData = await sumRes.json();
|
|
setPlatforms(platData.platforms || []);
|
|
setSummary(sumData);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
const handleTileClick = async (platform) => {
|
|
const key = `${platform.platform_vendor}|${platform.platform_model}`;
|
|
if (expandedTile === key) {
|
|
setExpandedTile(null);
|
|
setTileDetails(null);
|
|
return;
|
|
}
|
|
setExpandedTile(key);
|
|
setDetailLoading(true);
|
|
setTileDetails(null);
|
|
try {
|
|
const res = await fetch(
|
|
`${API_BASE}/scan-posture/platforms/${encodeURIComponent(platform.platform_vendor)}/${encodeURIComponent(platform.platform_model)}/details`,
|
|
{ credentials: 'include' }
|
|
);
|
|
if (!res.ok) throw new Error(`Details: ${res.status}`);
|
|
const data = await res.json();
|
|
setTileDetails(data);
|
|
} catch (_err) {
|
|
setTileDetails({ os_versions: [] });
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCollapse = () => {
|
|
setExpandedTile(null);
|
|
setTileDetails(null);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={styles.loadingContainer}>
|
|
<RefreshCw style={{ width: 24, height: 24, color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
|
|
<span style={{ color: '#94A3B8', marginLeft: 8 }}>Loading scan posture data...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div style={styles.errorContainer}>
|
|
<AlertTriangle style={{ width: 20, height: 20, color: '#F59E0B' }} />
|
|
<span style={{ color: '#F59E0B', marginLeft: 8 }}>{error}</span>
|
|
<button onClick={fetchData} style={styles.retryBtn}>Retry</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={styles.page}>
|
|
<div style={styles.header}>
|
|
<div>
|
|
<h1 style={styles.title}>Scan Posture</h1>
|
|
<p style={styles.subtitle}>Platform and code version rollup — executive view</p>
|
|
</div>
|
|
<button onClick={fetchData} style={styles.refreshBtn}>
|
|
<RefreshCw style={{ width: 14, height: 14 }} />
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
{summary && <SummaryBar summary={summary} />}
|
|
|
|
<TileGrid
|
|
platforms={platforms}
|
|
expandedTile={expandedTile}
|
|
tileDetails={tileDetails}
|
|
detailLoading={detailLoading}
|
|
onTileClick={handleTileClick}
|
|
onCollapse={handleCollapse}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Summary Bar
|
|
// ---------------------------------------------------------------------------
|
|
function SummaryBar({ summary }) {
|
|
const stats = [
|
|
{ label: 'Platforms', value: summary.platform_count || 0, icon: Monitor, color: '#0EA5E9' },
|
|
{ label: 'Devices', value: summary.total_devices || 0, icon: Server, color: '#10B981' },
|
|
{ label: 'Findings', value: summary.total_findings || 0, icon: Shield, color: '#F59E0B' },
|
|
{ label: 'Critical', value: summary.critical_findings || 0, icon: AlertTriangle, color: '#EF4444' },
|
|
{ label: 'Last Scan', value: formatDate(summary.last_scan_time), icon: Clock, color: '#8B5CF6' },
|
|
];
|
|
|
|
return (
|
|
<div style={styles.summaryBar}>
|
|
{stats.map(({ label, value, icon: Icon, color }) => (
|
|
<div key={label} style={styles.summaryItem}>
|
|
<Icon style={{ width: 14, height: 14, color, flexShrink: 0 }} />
|
|
<span style={styles.summaryValue}>{typeof value === 'number' ? value.toLocaleString() : value}</span>
|
|
<span style={styles.summaryLabel}>{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tile Grid
|
|
// ---------------------------------------------------------------------------
|
|
function TileGrid({ platforms, expandedTile, tileDetails, detailLoading, onTileClick, onCollapse }) {
|
|
if (platforms.length === 0) {
|
|
return (
|
|
<div style={styles.emptyState}>
|
|
<Monitor style={{ width: 40, height: 40, color: '#334155' }} />
|
|
<p style={{ color: '#64748B', marginTop: 12 }}>No platform data available yet.</p>
|
|
<p style={{ color: '#475569', fontSize: '0.75rem' }}>OS data is populated during Ivanti sync.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={styles.tileGrid}>
|
|
{platforms.map((p) => {
|
|
const key = `${p.platform_vendor}|${p.platform_model}`;
|
|
const isExpanded = expandedTile === key;
|
|
return (
|
|
<div key={key} style={{ gridColumn: isExpanded ? '1 / -1' : undefined }}>
|
|
<PlatformTile
|
|
platform={p}
|
|
isExpanded={isExpanded}
|
|
details={isExpanded ? tileDetails : null}
|
|
detailLoading={isExpanded && detailLoading}
|
|
onClick={() => onTileClick(p)}
|
|
onCollapse={onCollapse}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Platform Tile (expandable)
|
|
// ---------------------------------------------------------------------------
|
|
function PlatformTile({ platform, isExpanded, details, detailLoading, onClick, onCollapse }) {
|
|
const color = vendorColor(platform.platform_vendor);
|
|
const [hovered, setHovered] = useState(false);
|
|
const model = formatModel(platform.platform_model);
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
...styles.tile,
|
|
borderColor: isExpanded ? `${color}60` : hovered ? `${color}50` : `${color}25`,
|
|
background: isExpanded ? `${color}08` : hovered ? `${color}06` : 'rgba(15, 23, 42, 0.6)',
|
|
transform: !isExpanded && hovered ? 'translateY(-1px)' : 'none',
|
|
}}
|
|
onMouseEnter={() => setHovered(true)}
|
|
onMouseLeave={() => setHovered(false)}
|
|
>
|
|
<button onClick={onClick} style={styles.tileClickArea}>
|
|
<div style={styles.tileHeader}>
|
|
<div style={{ ...styles.tileIcon, background: `${color}18`, borderColor: `${color}40` }}>
|
|
<Monitor style={{ width: 16, height: 16, color }} />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ ...styles.tileName, color }}>{platform.platform_vendor}</div>
|
|
{model && <div style={styles.tileModel}>{model}</div>}
|
|
</div>
|
|
<div style={styles.tileHeaderRight}>
|
|
<div style={styles.tileScanBadge}>
|
|
{platform.primary_scan_type === 'agent' ? (
|
|
<Server style={{ width: 10, height: 10, color: '#10B981' }} />
|
|
) : (
|
|
<Wifi style={{ width: 10, height: 10, color: '#F59E0B' }} />
|
|
)}
|
|
<span style={{ fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase' }}>
|
|
{scanTypeLabel(platform.primary_scan_type)}
|
|
</span>
|
|
</div>
|
|
{isExpanded ? (
|
|
<ChevronUp style={{ width: 14, height: 14, color: '#64748B' }} />
|
|
) : (
|
|
<ChevronDown style={{ width: 14, height: 14, color: '#475569' }} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={styles.tileStats}>
|
|
<div style={styles.tileStat}>
|
|
<span style={styles.tileStatValue}>{Number(platform.device_count).toLocaleString()}</span>
|
|
<span style={styles.tileStatLabel}>Devices</span>
|
|
</div>
|
|
<div style={styles.tileStat}>
|
|
<span style={styles.tileStatValue}>{Number(platform.finding_count).toLocaleString()}</span>
|
|
<span style={styles.tileStatLabel}>Findings</span>
|
|
</div>
|
|
{Number(platform.critical_count) > 0 && (
|
|
<div style={styles.tileStat}>
|
|
<span style={{ ...styles.tileStatValue, color: SEVERITY_COLORS.critical }}>
|
|
{Number(platform.critical_count).toLocaleString()}
|
|
</span>
|
|
<span style={styles.tileStatLabel}>Critical</span>
|
|
</div>
|
|
)}
|
|
{Number(platform.high_count) > 0 && (
|
|
<div style={styles.tileStat}>
|
|
<span style={{ ...styles.tileStatValue, color: SEVERITY_COLORS.high }}>
|
|
{Number(platform.high_count).toLocaleString()}
|
|
</span>
|
|
<span style={styles.tileStatLabel}>High</span>
|
|
</div>
|
|
)}
|
|
<div style={{ ...styles.tileStat, marginLeft: 'auto' }}>
|
|
<span style={{ ...styles.tileStatValue, fontSize: '0.75rem', color: '#94A3B8' }}>
|
|
{formatDate(platform.last_scan_time)}
|
|
</span>
|
|
<span style={styles.tileStatLabel}>Last Scan</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Expanded detail section */}
|
|
{isExpanded && (
|
|
<div style={styles.expandedSection}>
|
|
<div style={styles.expandedHeader}>
|
|
<span style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
{platform.platform_vendor} {model || ''} — Code Versions & CVEs
|
|
</span>
|
|
<button onClick={onCollapse} style={styles.collapseBtn}>
|
|
<X style={{ width: 12, height: 12 }} />
|
|
Collapse
|
|
</button>
|
|
</div>
|
|
|
|
{detailLoading ? (
|
|
<div style={{ padding: '1.5rem', textAlign: 'center' }}>
|
|
<RefreshCw style={{ width: 16, height: 16, color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
|
|
</div>
|
|
) : details && details.os_versions ? (
|
|
<div style={styles.expandedContent}>
|
|
{details.os_versions.map((osVersion) => (
|
|
<OSVersionSection key={osVersion.os_name} osVersion={osVersion} color={color} />
|
|
))}
|
|
{details.os_versions.length === 0 && (
|
|
<div style={{ padding: '1rem', textAlign: 'center', color: '#64748B', fontSize: '0.75rem' }}>
|
|
No detailed data available for this platform.
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OS Version Section (collapsible, shows CVEs and hostnames)
|
|
// ---------------------------------------------------------------------------
|
|
function OSVersionSection({ osVersion, color }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const hostCount = osVersion.host_count || 0;
|
|
const hasCves = osVersion.cves && osVersion.cves.length > 0;
|
|
const hasHostnames = osVersion.hostnames && osVersion.hostnames.length > 0;
|
|
|
|
return (
|
|
<div style={styles.osSection}>
|
|
<button onClick={() => setExpanded(!expanded)} style={styles.osSectionHeader}>
|
|
{expanded ? (
|
|
<ChevronDown style={{ width: 12, height: 12, color: '#64748B', flexShrink: 0 }} />
|
|
) : (
|
|
<ChevronRight style={{ width: 12, height: 12, color: '#64748B', flexShrink: 0 }} />
|
|
)}
|
|
<span style={{ ...styles.osName, color }}>{osVersion.os_name}</span>
|
|
<span style={styles.osMeta}>{hostCount} host{hostCount !== 1 ? 's' : ''}</span>
|
|
<span style={styles.osMeta}>{osVersion.finding_count || 0} finding{osVersion.finding_count !== 1 ? 's' : ''}</span>
|
|
{osVersion.max_severity && (
|
|
<span style={{
|
|
...styles.severityBadge,
|
|
background: osVersion.max_severity >= 9 ? `${SEVERITY_COLORS.critical}15` : osVersion.max_severity >= 7 ? `${SEVERITY_COLORS.high}15` : `${SEVERITY_COLORS.medium}15`,
|
|
color: osVersion.max_severity >= 9 ? SEVERITY_COLORS.critical : osVersion.max_severity >= 7 ? SEVERITY_COLORS.high : SEVERITY_COLORS.medium,
|
|
}}>
|
|
{Number(osVersion.max_severity).toFixed(1)}
|
|
</span>
|
|
)}
|
|
{hasCves && (
|
|
<span style={styles.cveBadge}>{osVersion.cves.length} CVE{osVersion.cves.length !== 1 ? 's' : ''}</span>
|
|
)}
|
|
</button>
|
|
|
|
{expanded && (
|
|
<div style={styles.osExpandedContent}>
|
|
{hasCves && (
|
|
<div style={styles.osSubSection}>
|
|
<div style={styles.osSubLabel}>CVEs</div>
|
|
<div style={styles.osCveList}>
|
|
{osVersion.cves.map((cve) => (
|
|
<a
|
|
key={cve}
|
|
href={`https://nvd.nist.gov/vuln/detail/${cve}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{ ...styles.hostCveLink, color }}
|
|
>
|
|
{cve}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{hasHostnames && (
|
|
<div style={styles.osSubSection}>
|
|
<div style={styles.osSubLabel}>Hosts</div>
|
|
<div style={styles.osHostnameList}>
|
|
{osVersion.hostnames.map((name) => (
|
|
<span key={name} style={styles.hostnameChip}>{name}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Styles
|
|
// ---------------------------------------------------------------------------
|
|
const styles = {
|
|
page: { padding: '1.5rem', maxWidth: '1400px', margin: '0 auto' },
|
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
|
|
title: { fontSize: '1.25rem', fontWeight: '700', color: '#E2E8F0', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em', margin: 0 },
|
|
subtitle: { fontSize: '0.75rem', color: '#64748B', margin: '2px 0 0 0' },
|
|
refreshBtn: { display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.375rem 0.75rem', background: 'rgba(14, 165, 233, 0.08)', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '6px', color: '#0EA5E9', fontSize: '0.7rem', fontFamily: 'monospace', textTransform: 'uppercase', cursor: 'pointer' },
|
|
retryBtn: { marginLeft: 12, padding: '4px 12px', background: 'rgba(245, 158, 11, 0.15)', border: '1px solid rgba(245, 158, 11, 0.4)', borderRadius: '4px', color: '#F59E0B', fontSize: '0.7rem', cursor: 'pointer' },
|
|
summaryBar: { display: 'flex', gap: '1.5rem', padding: '0.875rem 1.25rem', background: 'rgba(15, 23, 42, 0.5)', border: '1px solid rgba(51, 65, 85, 0.4)', borderRadius: '8px', marginBottom: '1.5rem', flexWrap: 'wrap' },
|
|
summaryItem: { display: 'flex', alignItems: 'center', gap: '0.375rem' },
|
|
summaryValue: { fontSize: '0.85rem', fontWeight: '600', color: '#E2E8F0', fontFamily: 'monospace' },
|
|
summaryLabel: { fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.04em' },
|
|
tileGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '0.75rem' },
|
|
tile: { borderRadius: '10px', border: '1px solid', transition: 'all 0.15s ease', overflow: 'hidden' },
|
|
tileClickArea: { display: 'flex', flexDirection: 'column', padding: '0.875rem 1rem', cursor: 'pointer', textAlign: 'left', width: '100%', background: 'none', border: 'none', color: 'inherit' },
|
|
tileHeader: { display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '0.5rem' },
|
|
tileIcon: { width: '30px', height: '30px', borderRadius: '6px', border: '1px solid', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
|
|
tileName: { fontSize: '0.8rem', fontWeight: '700', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.04em', lineHeight: 1.2 },
|
|
tileModel: { fontSize: '0.65rem', color: '#94A3B8', fontFamily: 'monospace', marginTop: '1px' },
|
|
tileHeaderRight: { display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: 'auto' },
|
|
tileScanBadge: { display: 'flex', alignItems: 'center', gap: '3px', padding: '2px 6px', background: 'rgba(30, 41, 59, 0.6)', borderRadius: '4px' },
|
|
tileStats: { display: 'flex', gap: '1rem', alignItems: 'flex-end' },
|
|
tileStat: { display: 'flex', flexDirection: 'column' },
|
|
tileStatValue: { fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', fontFamily: 'monospace' },
|
|
tileStatLabel: { fontSize: '0.55rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.04em' },
|
|
// Expanded section
|
|
expandedSection: { borderTop: '1px solid rgba(51, 65, 85, 0.3)', background: 'rgba(15, 23, 42, 0.3)' },
|
|
expandedHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.5rem 1rem', borderBottom: '1px solid rgba(51, 65, 85, 0.2)' },
|
|
collapseBtn: { display: 'flex', alignItems: 'center', gap: '4px', padding: '3px 8px', background: 'rgba(100, 116, 139, 0.15)', border: '1px solid rgba(100, 116, 139, 0.3)', borderRadius: '4px', color: '#94A3B8', fontSize: '0.6rem', fontFamily: 'monospace', textTransform: 'uppercase', cursor: 'pointer' },
|
|
expandedContent: { padding: '0.5rem 0.75rem', maxHeight: '500px', overflowY: 'auto' },
|
|
// OS Version sections
|
|
osSection: { marginBottom: '0.25rem', borderRadius: '6px', border: '1px solid rgba(51, 65, 85, 0.2)', overflow: 'hidden' },
|
|
osSectionHeader: { display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', width: '100%', background: 'rgba(30, 41, 59, 0.4)', border: 'none', cursor: 'pointer', textAlign: 'left', color: 'inherit' },
|
|
osName: { fontSize: '0.75rem', fontWeight: '600', fontFamily: 'monospace', flex: 1 },
|
|
osMeta: { fontSize: '0.6rem', color: '#64748B', whiteSpace: 'nowrap' },
|
|
// OS version CVE list & badges
|
|
severityBadge: { padding: '1px 5px', borderRadius: '3px', fontSize: '0.6rem', fontWeight: '700', fontFamily: 'monospace' },
|
|
cveBadge: { fontSize: '0.55rem', color: '#94A3B8', background: 'rgba(51, 65, 85, 0.4)', padding: '1px 5px', borderRadius: '3px' },
|
|
osExpandedContent: { padding: '0.5rem 0.75rem 0.5rem 1.75rem' },
|
|
osSubSection: { marginBottom: '0.5rem' },
|
|
osSubLabel: { fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem', fontFamily: 'monospace' },
|
|
osCveList: { display: 'flex', flexWrap: 'wrap', gap: '0.375rem' },
|
|
hostCveLink: { fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '500', textDecoration: 'none', padding: '1px 4px', borderRadius: '3px', background: 'rgba(30, 41, 59, 0.5)' },
|
|
osHostnameList: { display: 'flex', flexWrap: 'wrap', gap: '0.375rem' },
|
|
hostnameChip: { fontSize: '0.6rem', fontFamily: 'monospace', color: '#CBD5E1', padding: '2px 6px', borderRadius: '3px', background: 'rgba(30, 41, 59, 0.5)', border: '1px solid rgba(51, 65, 85, 0.3)' },
|
|
// States
|
|
loadingContainer: { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' },
|
|
errorContainer: { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' },
|
|
emptyState: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' },
|
|
};
|