Initial commit - frontend
This commit is contained in:
380
src/app/(protected)/software/page.tsx
Normal file
380
src/app/(protected)/software/page.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user