Files
ld-sysinfo-frontend/src/app/(protected)/software/page.tsx
2025-09-19 03:26:52 +00:00

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