feat(vuln): paginate unmatched-software endpoint instead of capping at 100

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-28 23:57:00 +02:00
parent e88c825b56
commit 637bd9a1f1
3 changed files with 140 additions and 100 deletions
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web;
using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services;
@@ -34,6 +35,11 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
_globalAdminService = globalAdminService; _globalAdminService = globalAdminService;
} }
// Server-side pagination bounds. Aggregation runs on the full inventory; only the response
// slice is paginated, so callers can iterate through every unmatched entry.
private const int DefaultTake = 50;
private const int MaxTake = 200;
[Function("GetUnmatchedSoftware")] [Function("GetUnmatchedSoftware")]
public async Task<HttpResponseData> Run( public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "vulnerability/unmatched-software")] HttpRequestData req) [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "vulnerability/unmatched-software")] HttpRequestData req)
@@ -43,6 +49,14 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
// Global Admin only — enforced by PolicyEnforcementMiddleware // Global Admin only — enforced by PolicyEnforcementMiddleware
var userIdentifier = TenantHelper.GetUserIdentifier(req); var userIdentifier = TenantHelper.GetUserIdentifier(req);
var query = HttpUtility.ParseQueryString(req.Url.Query);
var skip = 0;
var take = DefaultTake;
if (int.TryParse(query["skip"], out var parsedSkip) && parsedSkip >= 0)
skip = parsedSkip;
if (int.TryParse(query["take"], out var parsedTake) && parsedTake >= 1)
take = Math.Min(parsedTake, MaxTake);
// Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri) // Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri)
var allEntities = await _vulnRepo.GetAllSoftwareInventoryAsync(); var allEntities = await _vulnRepo.GetAllSoftwareInventoryAsync();
@@ -102,13 +116,16 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
} }
} }
var result = unmatchedAgg.Values // Stable order (Frequency desc, then case-insensitive name) so paging is deterministic.
var ordered = unmatchedAgg.Values
.OrderByDescending(x => x.Frequency) .OrderByDescending(x => x.Frequency)
.Take(100) .ThenBy(x => x.SoftwareName, StringComparer.OrdinalIgnoreCase);
.ToList();
var total = unmatchedAgg.Count;
var page = ordered.Skip(skip).Take(take).ToList();
var response = req.CreateResponse(HttpStatusCode.OK); var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { software = result, total = result.Count }); await response.WriteAsJsonAsync(new { software = page, total, skip, take });
return response; return response;
} }
catch (Exception ex) catch (Exception ex)
@@ -23,12 +23,13 @@ export function UnmappedSoftwareTab({
onCountChanged, onCountChanged,
refreshTrigger, refreshTrigger,
}: UnmappedSoftwareTabProps) { }: UnmappedSoftwareTabProps) {
// Unmapped state // Unmapped state — server-side pagination
const pageSize = 20;
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [initialLoaded, setInitialLoaded] = useState(false);
const [entries, setEntries] = useState<UnmatchedSoftwareEntry[]>([]); const [entries, setEntries] = useState<UnmatchedSoftwareEntry[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
const pageSize = 20;
// CPE mapping state // CPE mapping state
const [expandedMappingRow, setExpandedMappingRow] = useState<string | null>(null); const [expandedMappingRow, setExpandedMappingRow] = useState<string | null>(null);
@@ -36,8 +37,8 @@ export function UnmappedSoftwareTab({
const [savingMapping, setSavingMapping] = useState<string | null>(null); const [savingMapping, setSavingMapping] = useState<string | null>(null);
const [savedMappings, setSavedMappings] = useState<Set<string>>(new Set()); const [savedMappings, setSavedMappings] = useState<Set<string>>(new Set());
// Bulk auto-resolve state // Bulk auto-resolve state — selection persists across pages, keyed by softwareName::publisher
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set()); const [selectedEntries, setSelectedEntries] = useState<Map<string, UnmatchedSoftwareEntry>>(new Map());
const [autoResolving, setAutoResolving] = useState(false); const [autoResolving, setAutoResolving] = useState(false);
const [autoResolveProgress, setAutoResolveProgress] = useState<{ const [autoResolveProgress, setAutoResolveProgress] = useState<{
batchIndex: number; batchIndex: number;
@@ -57,62 +58,74 @@ export function UnmappedSoftwareTab({
// --- Fetching --- // --- Fetching ---
const fetchUnmatchedSoftware = useCallback(async () => { // Server-paginated fetch. Returns the server total so callers can react to range changes.
try { const fetchPage = useCallback(
setLoading(true); async (page: number): Promise<number | null> => {
setError(null); try {
setLoading(true);
setError(null);
const response = await authenticatedFetch( const response = await authenticatedFetch(
api.vulnerability.unmatchedSoftware(), api.vulnerability.unmatchedSoftware(page * pageSize, pageSize),
getAccessToken getAccessToken
); );
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load unmatched software: ${response.statusText}`); throw new Error(`Failed to load unmatched software: ${response.statusText}`);
}
const data = await response.json();
const items: UnmatchedSoftwareEntry[] = data.software || [];
const serverTotal: number = data.total ?? items.length;
setEntries(items);
setTotal(serverTotal);
setInitialLoaded(true);
return serverTotal;
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while fetching unmatched software");
} else {
console.error("Error fetching unmatched software:", err);
}
setError(err instanceof Error ? err.message : "Failed to load unmatched software");
return null;
} finally {
setLoading(false);
} }
},
[getAccessToken, setError]
);
const data = await response.json(); // Refetch current page; if it slides past the new last page (after deletions), step back.
const sorted = (data.software || []).sort( const refreshCurrentPage = useCallback(async () => {
(a: UnmatchedSoftwareEntry, b: UnmatchedSoftwareEntry) => b.frequency - a.frequency const serverTotal = await fetchPage(currentPage);
); if (serverTotal === null) return;
setEntries(sorted); const lastPage = Math.max(0, Math.ceil(serverTotal / pageSize) - 1);
setTotal(data.total ?? sorted.length); if (currentPage > lastPage) {
setCurrentPage(0); setCurrentPage(lastPage);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while fetching unmatched software");
} else {
console.error("Error fetching unmatched software:", err);
}
setError(err instanceof Error ? err.message : "Failed to load unmatched software");
} finally {
setLoading(false);
} }
}, [getAccessToken, setError]); }, [currentPage, fetchPage]);
// Load on mount // Load on mount + whenever the page index changes
useEffect(() => { useEffect(() => {
if (entries.length === 0 && !loading) { fetchPage(currentPage);
fetchUnmatchedSoftware();
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [currentPage]);
// Refresh when parent signals (e.g. item restored from ignored tab) // Refresh when parent signals (e.g. item restored from ignored tab) — reset to page 0
useEffect(() => { useEffect(() => {
if (refreshTrigger > 0) { if (refreshTrigger > 0) {
fetchUnmatchedSoftware(); setSavedMappings(new Set());
setSelectedEntries(new Map());
if (currentPage !== 0) setCurrentPage(0);
else fetchPage(0);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshTrigger]); }, [refreshTrigger]);
// --- Helpers --- // --- Helpers ---
const totalPages = Math.ceil(entries.length / pageSize); const totalPages = Math.max(1, Math.ceil(total / pageSize));
const paginatedEntries = entries.slice(
currentPage * pageSize,
(currentPage + 1) * pageSize
);
const formatDate = (dateStr: string): string => { const formatDate = (dateStr: string): string => {
try { try {
@@ -197,39 +210,41 @@ export function UnmappedSoftwareTab({
}; };
// --- Selection --- // --- Selection ---
// Selection is keyed by row-key but stores the full entry, so bulk actions can run
// across pages without needing to re-fetch entries that have scrolled out of view.
const toggleRowSelection = (key: string) => { const toggleRowSelection = (entry: UnmatchedSoftwareEntry) => {
setSelectedRows((prev) => { const key = getRowKey(entry);
const next = new Set(prev); setSelectedEntries((prev) => {
const next = new Map(prev);
if (next.has(key)) next.delete(key); if (next.has(key)) next.delete(key);
else next.add(key); else next.set(key, entry);
return next; return next;
}); });
}; };
const selectAllOnPage = () => { const selectAllOnPage = () => {
const pageKeys = paginatedEntries setSelectedEntries((prev) => {
.filter((e) => !savedMappings.has(getRowKey(e))) const next = new Map(prev);
.map((e) => getRowKey(e)); for (const e of entries) {
setSelectedRows((prev) => { if (!savedMappings.has(getRowKey(e))) next.set(getRowKey(e), e);
const next = new Set(prev); }
pageKeys.forEach((k) => next.add(k));
return next; return next;
}); });
}; };
const deselectAll = () => setSelectedRows(new Set()); const deselectAll = () => setSelectedEntries(new Map());
const allOnPageSelected = paginatedEntries.length > 0 && const allOnPageSelected = entries.length > 0 &&
paginatedEntries entries
.filter((e) => !savedMappings.has(getRowKey(e))) .filter((e) => !savedMappings.has(getRowKey(e)))
.every((e) => selectedRows.has(getRowKey(e))); .every((e) => selectedEntries.has(getRowKey(e)));
// --- Auto-resolve --- // --- Auto-resolve ---
const handleAutoResolveSelected = async () => { const handleAutoResolveSelected = async () => {
const selectedEntries = entries.filter((e) => selectedRows.has(getRowKey(e))); const selectedList = Array.from(selectedEntries.values());
if (selectedEntries.length === 0) return; if (selectedList.length === 0) return;
setAutoResolving(true); setAutoResolving(true);
setAutoResolveResults(null); setAutoResolveResults(null);
@@ -237,8 +252,8 @@ export function UnmappedSoftwareTab({
const BATCH_SIZE = 25; const BATCH_SIZE = 25;
const batches: UnmatchedSoftwareEntry[][] = []; const batches: UnmatchedSoftwareEntry[][] = [];
for (let i = 0; i < selectedEntries.length; i += BATCH_SIZE) { for (let i = 0; i < selectedList.length; i += BATCH_SIZE) {
batches.push(selectedEntries.slice(i, i + BATCH_SIZE)); batches.push(selectedList.slice(i, i + BATCH_SIZE));
} }
const cumulativeResolved: AutoResolveResult["resolved"] = []; const cumulativeResolved: AutoResolveResult["resolved"] = [];
@@ -277,9 +292,9 @@ export function UnmappedSoftwareTab({
cumulativeResolved.push(...data.resolved); cumulativeResolved.push(...data.resolved);
cumulativeFailed.push(...data.failed); cumulativeFailed.push(...data.failed);
// Mark resolved items // Mark resolved items so the row badge flips to "Mapped" without a refetch
data.resolved.forEach((r) => { data.resolved.forEach((r) => {
const entry = selectedEntries.find((e) => e.softwareName === r.softwareName); const entry = selectedList.find((e) => e.softwareName === r.softwareName);
if (entry) setSavedMappings((prev) => new Set(prev).add(getRowKey(entry))); if (entry) setSavedMappings((prev) => new Set(prev).add(getRowKey(entry)));
}); });
} }
@@ -291,11 +306,11 @@ export function UnmappedSoftwareTab({
totalResolved: cumulativeResolved.length, totalResolved: cumulativeResolved.length,
totalFailed: cumulativeFailed.length, totalFailed: cumulativeFailed.length,
}); });
setSelectedRows(new Set()); setSelectedEntries(new Map());
onMappingChanged(); onMappingChanged();
trackEvent("bulk_auto_resolve_completed", { trackEvent("bulk_auto_resolve_completed", {
totalSelected: selectedEntries.length.toString(), totalSelected: selectedList.length.toString(),
totalResolved: cumulativeResolved.length.toString(), totalResolved: cumulativeResolved.length.toString(),
totalFailed: cumulativeFailed.length.toString(), totalFailed: cumulativeFailed.length.toString(),
}); });
@@ -322,11 +337,9 @@ export function UnmappedSoftwareTab({
}), }),
}); });
if (!response.ok) throw new Error(`Failed to ignore software: ${response.statusText}`); if (!response.ok) throw new Error(`Failed to ignore software: ${response.statusText}`);
// Remove from unmapped list setSelectedEntries((prev) => { const n = new Map(prev); n.delete(key); return n; });
setEntries((prev) => prev.filter((e) => getRowKey(e) !== key));
setTotal((prev) => Math.max(0, prev - 1));
setSelectedRows((prev) => { const n = new Set(prev); n.delete(key); return n; });
onIgnored(); onIgnored();
await refreshCurrentPage();
} catch (err) { } catch (err) {
console.error("Error ignoring software:", err); console.error("Error ignoring software:", err);
setError(err instanceof Error ? err.message : "Failed to ignore software"); setError(err instanceof Error ? err.message : "Failed to ignore software");
@@ -336,8 +349,8 @@ export function UnmappedSoftwareTab({
}; };
const handleBulkIgnoreSelected = async () => { const handleBulkIgnoreSelected = async () => {
const selectedEntries = entries.filter((e) => selectedRows.has(getRowKey(e))); const selectedList = Array.from(selectedEntries.values());
if (selectedEntries.length === 0) return; if (selectedList.length === 0) return;
try { try {
setIgnoringRow("bulk"); setIgnoringRow("bulk");
@@ -346,15 +359,13 @@ export function UnmappedSoftwareTab({
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
items: selectedEntries.map((e) => ({ softwareName: e.softwareName, publisher: e.publisher || "", reason: "manual" })), items: selectedList.map((e) => ({ softwareName: e.softwareName, publisher: e.publisher || "", reason: "manual" })),
}), }),
}); });
if (!response.ok) throw new Error(`Failed to ignore software: ${response.statusText}`); if (!response.ok) throw new Error(`Failed to ignore software: ${response.statusText}`);
const keys = new Set(selectedEntries.map(getRowKey)); setSelectedEntries(new Map());
setEntries((prev) => prev.filter((e) => !keys.has(getRowKey(e))));
setTotal((prev) => Math.max(0, prev - selectedEntries.length));
setSelectedRows(new Set());
onIgnored(); onIgnored();
await refreshCurrentPage();
} catch (err) { } catch (err) {
console.error("Error bulk ignoring software:", err); console.error("Error bulk ignoring software:", err);
setError(err instanceof Error ? err.message : "Failed to ignore software"); setError(err instanceof Error ? err.message : "Failed to ignore software");
@@ -365,14 +376,16 @@ export function UnmappedSoftwareTab({
// --- Render --- // --- Render ---
// Full-page spinner only on initial load. Subsequent page fetches keep the table visible
// to avoid flicker; controls are disabled via the `loading` state.
return ( return (
<> <>
{loading ? ( {loading && !initialLoaded ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" /> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading unmatched software...</span> <span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading unmatched software...</span>
</div> </div>
) : entries.length === 0 ? ( ) : initialLoaded && total === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
@@ -387,8 +400,13 @@ export function UnmappedSoftwareTab({
<span className="font-semibold text-amber-600 dark:text-amber-400">{total}</span> unmatched software entries <span className="font-semibold text-amber-600 dark:text-amber-400">{total}</span> unmatched software entries
</span> </span>
<button <button
onClick={fetchUnmatchedSoftware} onClick={() => {
disabled={autoResolving} setSavedMappings(new Set());
setSelectedEntries(new Map());
if (currentPage !== 0) setCurrentPage(0);
else fetchPage(0);
}}
disabled={autoResolving || loading}
className="ml-auto text-sm text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 flex items-center gap-1.5 disabled:opacity-40" className="ml-auto text-sm text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 flex items-center gap-1.5 disabled:opacity-40"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
@@ -399,19 +417,19 @@ export function UnmappedSoftwareTab({
</div> </div>
{/* Selection toolbar */} {/* Selection toolbar */}
{(selectedRows.size > 0 || autoResolving) && ( {(selectedEntries.size > 0 || autoResolving) && (
<div className="flex flex-wrap items-center gap-3 pb-3 text-sm"> <div className="flex flex-wrap items-center gap-3 pb-3 text-sm">
<span className="text-gray-600 dark:text-gray-300 font-medium"> <span className="text-gray-600 dark:text-gray-300 font-medium">
{selectedRows.size} selected {selectedEntries.size} selected
</span> </span>
<button <button
onClick={allOnPageSelected ? deselectAll : selectAllOnPage} onClick={allOnPageSelected ? deselectAll : selectAllOnPage}
disabled={autoResolving} disabled={autoResolving}
className="text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors" className="text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
> >
{allOnPageSelected ? "Deselect All" : "Select All"} {allOnPageSelected ? "Deselect Page" : "Select Page"}
</button> </button>
{selectedRows.size > 0 && ( {selectedEntries.size > 0 && (
<button <button
onClick={deselectAll} onClick={deselectAll}
disabled={autoResolving} disabled={autoResolving}
@@ -422,14 +440,14 @@ export function UnmappedSoftwareTab({
)} )}
<button <button
onClick={handleBulkIgnoreSelected} onClick={handleBulkIgnoreSelected}
disabled={autoResolving || ignoringRow === "bulk" || selectedRows.size === 0} disabled={autoResolving || ignoringRow === "bulk" || selectedEntries.size === 0}
className="text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-red-50 hover:text-red-600 hover:border-red-300 dark:hover:bg-red-900/20 dark:hover:text-red-400 disabled:opacity-40 transition-colors" className="text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-red-50 hover:text-red-600 hover:border-red-300 dark:hover:bg-red-900/20 dark:hover:text-red-400 disabled:opacity-40 transition-colors"
> >
{ignoringRow === "bulk" ? "Ignoring..." : "Ignore Selected"} {ignoringRow === "bulk" ? "Ignoring..." : "Ignore Selected"}
</button> </button>
<button <button
onClick={handleAutoResolveSelected} onClick={handleAutoResolveSelected}
disabled={autoResolving || selectedRows.size === 0} disabled={autoResolving || selectedEntries.size === 0}
className="ml-auto text-xs px-3 py-1.5 rounded-md font-medium bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5" className="ml-auto text-xs px-3 py-1.5 rounded-md font-medium bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
> >
{autoResolving ? ( {autoResolving ? (
@@ -510,7 +528,7 @@ export function UnmappedSoftwareTab({
<th className="px-2 py-3"> <th className="px-2 py-3">
<input <input
type="checkbox" type="checkbox"
checked={allOnPageSelected && paginatedEntries.length > 0} checked={allOnPageSelected && entries.length > 0}
onChange={allOnPageSelected ? deselectAll : selectAllOnPage} onChange={allOnPageSelected ? deselectAll : selectAllOnPage}
disabled={autoResolving} disabled={autoResolving}
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40" className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
@@ -525,7 +543,7 @@ export function UnmappedSoftwareTab({
</tr> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{paginatedEntries.map((entry) => { {entries.map((entry) => {
const key = getRowKey(entry); const key = getRowKey(entry);
const isMapped = savedMappings.has(key); const isMapped = savedMappings.has(key);
const isMappingExpanded = expandedMappingRow === key; const isMappingExpanded = expandedMappingRow === key;
@@ -539,8 +557,8 @@ export function UnmappedSoftwareTab({
{!isMapped && ( {!isMapped && (
<input <input
type="checkbox" type="checkbox"
checked={selectedRows.has(key)} checked={selectedEntries.has(key)}
onChange={() => toggleRowSelection(key)} onChange={() => toggleRowSelection(entry)}
disabled={autoResolving} disabled={autoResolving}
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40" className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
/> />
@@ -692,16 +710,17 @@ export function UnmappedSoftwareTab({
</table> </table>
</div> </div>
{/* Pagination */} {/* Pagination — server-side; range computed from total */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md mt-2"> <div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md mt-2">
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-gray-500 dark:text-gray-400">
{currentPage * pageSize + 1}&ndash;{Math.min((currentPage + 1) * pageSize, entries.length)} of {entries.length} {total === 0 ? 0 : currentPage * pageSize + 1}&ndash;
{Math.min((currentPage + 1) * pageSize, total)} of {total}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => setCurrentPage((p) => p - 1)} onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0} disabled={currentPage === 0 || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
> >
Previous Previous
@@ -711,7 +730,7 @@ export function UnmappedSoftwareTab({
</span> </span>
<button <button
onClick={() => setCurrentPage((p) => p + 1)} onClick={() => setCurrentPage((p) => p + 1)}
disabled={(currentPage + 1) * pageSize >= entries.length} disabled={currentPage + 1 >= totalPages || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
> >
Next Next
+5 -1
View File
@@ -311,7 +311,11 @@ export const api = {
cpeMappings: () => `${API_BASE_URL}/api/vulnerability/cpe-mappings`, cpeMappings: () => `${API_BASE_URL}/api/vulnerability/cpe-mappings`,
cpeMapping: () => `${API_BASE_URL}/api/vulnerability/cpe-mapping`, cpeMapping: () => `${API_BASE_URL}/api/vulnerability/cpe-mapping`,
cpeAutoResolve: () => `${API_BASE_URL}/api/vulnerability/cpe-mapping/auto-resolve`, cpeAutoResolve: () => `${API_BASE_URL}/api/vulnerability/cpe-mapping/auto-resolve`,
unmatchedSoftware: () => `${API_BASE_URL}/api/vulnerability/unmatched-software`, unmatchedSoftware: (skip?: number, take?: number) =>
`${API_BASE_URL}/api/vulnerability/unmatched-software${qs({
skip: skip !== undefined ? String(skip) : undefined,
take: take !== undefined ? String(take) : undefined,
})}`,
ignoredSoftware: () => `${API_BASE_URL}/api/vulnerability/ignored-software`, ignoredSoftware: () => `${API_BASE_URL}/api/vulnerability/ignored-software`,
}, },