381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
|
Paper, Collapse, IconButton, Typography, Box, CircularProgress, TextField,
|
|
Dialog, DialogTitle, DialogContent, DialogActions, Button,Tooltip
|
|
} from '@mui/material';
|
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
|
import { FixedSizeList as List } from 'react-window';
|
|
import { useDeviceContext } from '@/context/DeviceContext';
|
|
import api from '@/lib/axios';
|
|
|
|
interface CachedSoftwareEntry {
|
|
id: number;
|
|
deviceId: number;
|
|
hostname: string;
|
|
softwareName: string;
|
|
appVersion: string;
|
|
publisher: string;
|
|
lastUpdated: string;
|
|
totalCves: number;
|
|
cveList: string;
|
|
}
|
|
|
|
interface DeviceVulnerability {
|
|
cveId: string;
|
|
title: string;
|
|
severity: string;
|
|
score?: number;
|
|
publishedDate: string;
|
|
lastModifiedDate: string;
|
|
}
|
|
|
|
|
|
|
|
|
|
interface GroupedSoftwareEntry {
|
|
softwareName: string;
|
|
totalDevices: number;
|
|
instances: CachedSoftwareEntry[];
|
|
}
|
|
|
|
export default function SoftwarePage() {
|
|
const [softwareList, setSoftwareList] = useState<GroupedSoftwareEntry[]>([]);
|
|
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [drawerPageStates, setDrawerPageStates] = useState<Record<number, { page: number, pageSize: number }>>({});
|
|
const [page, setPage] = useState(0); // Top-level page
|
|
const [pageSize, setPageSize] = useState(10); // Top-level pageSize (default 10)
|
|
const [openCveDialog, setOpenCveDialog] = useState(false);
|
|
const [selectedCves, setSelectedCves] = useState<DeviceVulnerability[]>([]);
|
|
const [selectedHostname, setSelectedHostname] = useState<string>('');
|
|
const { detailedCveLookup } = useDeviceContext();
|
|
const [sortMode, setSortMode] = useState('vulnerabilities_desc'); // Default if you want "vulnerable first"
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
const fetchSoftware = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const res = await api.get<CachedSoftwareEntry[]>('/cached/software/summary');
|
|
|
|
const grouped: { [key: string]: CachedSoftwareEntry[] } = {};
|
|
|
|
res.data.forEach(entry => {
|
|
if (!grouped[entry.softwareName]) {
|
|
grouped[entry.softwareName] = [];
|
|
}
|
|
grouped[entry.softwareName].push(entry);
|
|
});
|
|
|
|
let formatted = Object.entries(grouped).map(([softwareName, instances]) => ({
|
|
softwareName,
|
|
totalDevices: instances.length,
|
|
instances,
|
|
}));
|
|
|
|
// 🔥 Sort: put groups with the most vulnerable instances at the top
|
|
formatted = formatted.sort((a, b) => {
|
|
const aVulnerable = a.instances.some(inst => inst.totalCves > 0) ? 1 : 0;
|
|
const bVulnerable = b.instances.some(inst => inst.totalCves > 0) ? 1 : 0;
|
|
return bVulnerable - aVulnerable; // Vulnerable groups first
|
|
});
|
|
|
|
setSoftwareList(formatted);
|
|
} catch (err) {
|
|
console.error('❌ Failed to fetch cached software summary:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchSoftware();
|
|
}, []);
|
|
|
|
|
|
const toggleRow = (index: number) => {
|
|
setExpandedIndex(expandedIndex === index ? null : index);
|
|
|
|
// 🔥 Reset page state for that drawer
|
|
setDrawerPageStates(prev => ({
|
|
...prev,
|
|
[index]: { page: 0, pageSize: 10 }
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<div className="p-6 space-y-4">
|
|
<Typography variant="h4" gutterBottom>Installed Software (Cached)</Typography>
|
|
|
|
<Box display="flex" flexWrap="wrap" alignItems="center" justifyContent="space-between" gap={2} mt={2}>
|
|
<Typography variant="subtitle1" color="text.secondary">
|
|
Browse cached installed software across your environment. Sort and explore by device count, name, or vulnerability presence.
|
|
</Typography>
|
|
|
|
<Box display="flex" alignItems="center" gap={2}>
|
|
<TextField
|
|
select
|
|
label="Sort by"
|
|
value={sortMode}
|
|
onChange={(e) => {
|
|
setSortMode(e.target.value);
|
|
setPage(0);
|
|
setExpandedIndex(null);
|
|
}}
|
|
SelectProps={{ native: true }}
|
|
size="small"
|
|
sx={{ width: 220 }}
|
|
>
|
|
<option value="alpha_asc">Alphabetical (A → Z)</option>
|
|
<option value="alpha_desc">Alphabetical (Z → A)</option>
|
|
<option value="devices_asc">Total Devices (Ascending)</option>
|
|
<option value="devices_desc">Total Devices (Descending)</option>
|
|
<option value="vulnerabilities_asc">Vulnerabilities (Ascending)</option>
|
|
<option value="vulnerabilities_desc">Vulnerabilities (Descending)</option>
|
|
</TextField>
|
|
|
|
<TextField
|
|
select
|
|
label="Rows per page"
|
|
value={pageSize}
|
|
onChange={(e) => {
|
|
setPageSize(Number(e.target.value));
|
|
setPage(0);
|
|
}}
|
|
SelectProps={{ native: true }}
|
|
size="small"
|
|
sx={{ width: 120 }}
|
|
>
|
|
{[10, 25, 50, 100].map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</TextField>
|
|
|
|
<IconButton onClick={() => setPage((prev) => Math.max(prev - 1, 0))} disabled={page === 0}>
|
|
◀
|
|
</IconButton>
|
|
<IconButton onClick={() => setPage((prev) => (prev + 1) * pageSize < softwareList.length ? prev + 1 : prev)} disabled={(page + 1) * pageSize >= softwareList.length}>
|
|
▶
|
|
</IconButton>
|
|
</Box>
|
|
</Box>
|
|
{loading ? (
|
|
<Box display="flex" justifyContent="center"><CircularProgress /></Box>
|
|
) : (
|
|
<TableContainer component={Paper} elevation={1}>
|
|
<Table>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell />
|
|
<TableCell><strong>Software Name</strong></TableCell>
|
|
<TableCell align="center"><strong>Total Devices</strong></TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{[...softwareList]
|
|
.sort((a, b) => {
|
|
switch (sortMode) {
|
|
case 'alpha_asc':
|
|
return a.softwareName.localeCompare(b.softwareName);
|
|
case 'alpha_desc':
|
|
return b.softwareName.localeCompare(a.softwareName);
|
|
case 'devices_asc':
|
|
return a.totalDevices - b.totalDevices;
|
|
case 'devices_desc':
|
|
return b.totalDevices - a.totalDevices;
|
|
case 'vulnerabilities_asc':
|
|
const aVulns = a.instances.reduce((sum, inst) => sum + inst.totalCves, 0);
|
|
const bVulns = b.instances.reduce((sum, inst) => sum + inst.totalCves, 0);
|
|
return aVulns - bVulns;
|
|
case 'vulnerabilities_desc':
|
|
const aVulnsDesc = a.instances.reduce((sum, inst) => sum + inst.totalCves, 0);
|
|
const bVulnsDesc = b.instances.reduce((sum, inst) => sum + inst.totalCves, 0);
|
|
return bVulnsDesc - aVulnsDesc;
|
|
default:
|
|
return 0;
|
|
}
|
|
})
|
|
.slice(page * pageSize, (page + 1) * pageSize) // 🥇 paginate AFTER sorting
|
|
.map((row, index) => {
|
|
const globalIndex = index + page * pageSize; // ✅
|
|
|
|
return (
|
|
<React.Fragment key={row.softwareName}>
|
|
<TableRow
|
|
hover
|
|
onClick={() => toggleRow(globalIndex)} // ✅
|
|
sx={{ cursor: 'pointer' }}
|
|
>
|
|
<TableCell
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleRow(globalIndex);
|
|
}}
|
|
sx={{ cursor: 'pointer' }}
|
|
>
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleRow(globalIndex);
|
|
}}
|
|
>
|
|
{expandedIndex === globalIndex ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
|
</IconButton>
|
|
</TableCell>
|
|
<TableCell>{row.softwareName}</TableCell>
|
|
<TableCell align="center">{row.totalDevices}</TableCell>
|
|
</TableRow>
|
|
<TableRow>
|
|
<TableCell colSpan={3} sx={{ p: 0 }}>
|
|
<Collapse in={expandedIndex === globalIndex} timeout="auto" unmountOnExit>
|
|
<Box margin={2}>
|
|
<TableContainer component={Paper} variant="outlined">
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell><strong>Hostname</strong></TableCell>
|
|
<TableCell><strong>Version</strong></TableCell>
|
|
<TableCell><strong>Publisher</strong></TableCell>
|
|
<TableCell align="center"><strong>CVEs</strong></TableCell>
|
|
<TableCell><strong>Last Updated</strong></TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{row.instances.map((instance) => (
|
|
<TableRow
|
|
key={instance.id}
|
|
hover
|
|
onClick={() => {
|
|
const cveIds = instance.cveList.split(',');
|
|
const uniqueCveIds = Array.from(new Set(cveIds));
|
|
const fullDetails = uniqueCveIds
|
|
.map(cveId => {
|
|
const cve = detailedCveLookup[cveId];
|
|
if (cve) {
|
|
return {
|
|
cveId: cve.cveId,
|
|
title: cve.title,
|
|
severity: cve.severity,
|
|
score: cve.score,
|
|
publishedDate: cve.publishedDate ?? '',
|
|
lastModifiedDate: cve.lastModifiedDate ?? cve.publishedDate ?? '',
|
|
} as DeviceVulnerability;
|
|
}
|
|
return null;
|
|
})
|
|
.filter(Boolean) as DeviceVulnerability[];
|
|
|
|
setSelectedCves(fullDetails);
|
|
setSelectedHostname(instance.hostname);
|
|
setOpenCveDialog(true);
|
|
}}
|
|
sx={{ cursor: 'pointer' }}
|
|
>
|
|
<TableCell>{instance.hostname}</TableCell>
|
|
<TableCell>{instance.appVersion}</TableCell>
|
|
<TableCell>{instance.publisher || 'Unknown Publisher'}</TableCell>
|
|
<TableCell align="center">
|
|
{instance.cveList
|
|
? (
|
|
<Typography variant="body2" fontWeight={600} color="primary.main">
|
|
{Array.from(new Set(instance.cveList.split(','))).length}
|
|
</Typography>
|
|
)
|
|
: (
|
|
<Typography variant="body2" color="text.secondary">
|
|
None
|
|
</Typography>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>{new Date(instance.lastUpdated).toLocaleDateString()}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</Box>
|
|
</Collapse>
|
|
</TableCell>
|
|
</TableRow>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
)}
|
|
<Dialog sx={{ p: 0 }} open={openCveDialog} onClose={() => setOpenCveDialog(false)} fullWidth maxWidth="md">
|
|
<DialogTitle>CVEs for {selectedHostname}</DialogTitle>
|
|
<DialogContent sx={{ p: 0 }} dividers>
|
|
{selectedCves.length === 0 ? (
|
|
<Typography>No CVEs found.</Typography>
|
|
) : (
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell sx={{ minWidth: 150, borderRight: '1px solid rgba(81,81,81,1)' }}><strong>CVE ID</strong></TableCell>
|
|
<TableCell sx={{ minWidth: 100, borderRight: '1px solid rgba(81,81,81,1)' }}><strong>Severity</strong></TableCell>
|
|
<TableCell sx={{ minWidth: 80, borderRight: '1px solid rgba(81,81,81,1)' }}><strong>Score</strong></TableCell>
|
|
<TableCell sx={{ minWidth: 300, borderRight: '1px solid rgba(81,81,81,1)' }}><strong>Description</strong></TableCell>
|
|
<TableCell sx={{ minWidth: 130 }}><strong>Last Updated</strong></TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{selectedCves.map((cve, idx) => (
|
|
<TableRow key={idx}>
|
|
<TableCell sx={{ minWidth: 150, borderRight: '1px solid rgba(81,81,81,1)' }}>
|
|
<a
|
|
href={`https://nvd.nist.gov/vuln/detail/${cve.cveId}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{ color: '#1976d2', textDecoration: 'underline' }}
|
|
>
|
|
{cve.cveId}
|
|
</a>
|
|
</TableCell>
|
|
<TableCell sx={{ minWidth: 100, borderRight: '1px solid rgba(81,81,81,1)' }}>
|
|
{cve.severity
|
|
? cve.severity.charAt(0).toUpperCase() + cve.severity.slice(1).toLowerCase()
|
|
: 'Unknown'}
|
|
</TableCell>
|
|
<TableCell sx={{ minWidth: 80, borderRight: '1px solid rgba(81,81,81,1)' }}>
|
|
{cve.score === -1 || cve.score === undefined ? 'Unknown' : cve.score}
|
|
</TableCell>
|
|
<TableCell sx={{ minWidth: 300, borderRight: '1px solid rgba(81,81,81,1)' }}>
|
|
{cve.title || 'Unknown'}
|
|
</TableCell>
|
|
<TableCell sx={{ minWidth: 130 }}>
|
|
<Tooltip
|
|
title={`Published: ${new Date(cve.publishedDate).toLocaleDateString()}`}
|
|
arrow
|
|
placement="top"
|
|
disableInteractive
|
|
>
|
|
<span style={{ cursor: 'help', textDecoration: 'underline dotted' }}>
|
|
{new Date(cve.lastModifiedDate).toLocaleDateString()}
|
|
</span>
|
|
</Tooltip>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
|
|
</Table>
|
|
)}
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setOpenCveDialog(false)}>Close</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
</div>
|
|
);
|
|
}
|